Files
donutbrowser/src-tauri/src/mcp_server.rs
T
2026-10-19 05:22:46 +04:00

15513 lines
552 KiB
Rust

use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tauri::AppHandle;
use tokio::sync::Mutex as AsyncMutex;
use utoipa::ToSchema;
use uuid::Uuid;
use crate::browser::ProxySettings;
use crate::cdp_target::{CdpError, CdpTarget};
use crate::cloud_auth::CLOUD_AUTH;
use crate::group_manager::GROUP_MANAGER;
use crate::log_redaction::ShortId;
use crate::profile::{BrowserProfile, ProfileManager};
use crate::proxy_manager::PROXY_MANAGER;
use crate::wayfern_cdp::{
self, vellum, Engine, Extraction, ExtractionRequest, LocatorCandidate, LocatorDescription,
LocatorResolution, PerceptionFrame, PerceptionNode, PerceptionPage, PerceptionRequest,
PerceptionStats, PickedElement, ResolveOptions, ViewportTarget, WayfernError, WayfernSession,
};
static AUTOMATION_QUOTA: crate::log_streak::Throttle =
crate::log_streak::Throttle::new(std::time::Duration::from_secs(60));
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpTool {
pub name: String,
pub description: String,
pub input_schema: serde_json::Value,
}
/// JavaScript executed in the target page to enumerate visible interactive
/// elements. Returns a JSON string `{elements, count, truncated}` where
/// `elements` is the newline-joined labeled list. Live references are stashed
/// on a per-caller `window[...]` slot so subsequent `click_by_index` /
/// `type_by_index` calls can resolve `index → Element` without round-tripping
/// a selector. `__MAX_CHARS__`, `__CACHE__`, `__REGISTRY__` and `__MAX_SLOTS__`
/// are substituted at call time.
const INTERACTIVE_ELEMENTS_JS: &str = r#"(() => {
const SELECTORS = 'a, button, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="tab"], [role="menuitem"], [role="combobox"], [role="option"], [contenteditable=""], [contenteditable="true"], [tabindex]:not([tabindex="-1"])';
const ATTRS = ['type','name','id','role','aria-label','aria-checked','aria-expanded','placeholder','title','value','href','alt'];
const MAX_CHARS = __MAX_CHARS__;
const interactive = [];
const lines = [];
let truncated = false;
let total = 0;
// An id per element that stays the same across listings while the element
// lives, so a caller can act on `e12` after the page moved things around,
// and can tell which elements are new since its last listing.
let ids = window.__donut_element_ids;
if (!ids) {
ids = { map: new WeakMap(), next: 1, seen: {} };
try {
Object.defineProperty(window, '__donut_element_ids', { value: ids, enumerable: false, configurable: true });
} catch (e) {
window.__donut_element_ids = ids;
}
}
const seenBefore = ids.seen[__CACHE__];
const seenNow = new Set();
const nodes = document.querySelectorAll(SELECTORS);
for (const el of nodes) {
if (el.disabled) continue;
const r = el.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) continue;
const style = window.getComputedStyle(el);
if (style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') continue;
const tag = el.tagName.toLowerCase();
const parts = [];
for (const a of ATTRS) {
const v = el.getAttribute(a);
if (v) parts.push(a + '="' + String(v).slice(0,100).replace(/"/g,'\\"') + '"');
}
let text = '';
if (!['INPUT','TEXTAREA','SELECT'].includes(el.tagName)) {
text = (el.innerText || el.textContent || '').trim().replace(/\s+/g,' ').slice(0,100);
}
const idx = interactive.length;
let eid = ids.map.get(el);
if (!eid) { eid = ids.next++; ids.map.set(el, eid); }
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
const inView = cx >= 0 && cy >= 0 && cx < window.innerWidth && cy < window.innerHeight;
let covered = false;
if (inView) {
const hit = document.elementFromPoint(cx, cy);
covered = !!hit && hit !== el && !el.contains(hit) && !(hit.tagName === 'LABEL' && hit.control === el);
}
const marks = ' e' + eid
+ (seenBefore && !seenBefore.has(eid) ? ' new' : '')
+ (!inView ? ' offscreen' : covered ? ' covered' : '');
const line = '[' + idx + marks + ']<' + tag + (parts.length ? ' ' + parts.join(' ') : '') + '>' + text + '</' + tag + '>';
if (total + line.length + 1 > MAX_CHARS) { truncated = true; break; }
total += line.length + 1;
interactive.push(el);
lines.push(line);
seenNow.add(eid);
}
window[__CACHE__] = interactive;
ids.seen[__CACHE__] = seenNow;
// Bound how many snapshots one page carries. Each slot holds live element
// references, so it pins every node in it, including nodes the page has
// since detached, for as long as the tab lives, and NOTHING on the page
// ever hears that a session ended. `end_session` deletes a session's own
// slot, but no first-party client sends one, and an evicted or crashed
// session never will; without this cap a long-lived tab accumulates one
// array per session that ever listed it. Evicting the least recently
// written slot is safe in the way that matters: the evicted caller's next
// click_by_index finds no array and is told to re-list, which is an error,
// not a click on the wrong element.
try {
const registry = Array.isArray(window[__REGISTRY__]) ? window[__REGISTRY__] : [];
const kept = registry.filter((slot) => typeof slot === 'string' && slot !== __CACHE__ && slot in window);
kept.push(__CACHE__);
while (kept.length > __MAX_SLOTS__) {
const evicted = kept.shift();
try { delete window[evicted]; } catch (e) { window[evicted] = undefined; }
}
window[__REGISTRY__] = kept;
} catch (e) {
// A page that has made the registry unwritable only keeps its own slots
// alive; it must not cost the caller the listing it asked for.
}
return JSON.stringify({ elements: lines.join('\n'), count: interactive.length, truncated: truncated });
})()"#;
/// Page global naming, in write order, every interactive-element slot the page
/// currently holds. Read and rewritten by the enumeration script so the oldest
/// slot can be evicted once [`MAX_CACHE_SLOTS_PER_PAGE`] is exceeded.
///
/// A quoted JS string literal, because it is substituted into `window[...]`.
const INTERACTIVE_SLOT_REGISTRY: &str = "'__donut_interactive_slots'";
/// How many interactive-element snapshots one page keeps at once.
///
/// Far above what any real page needs, a caller has one live snapshot, and
/// the handful of callers driving one page at the same moment are a browser
/// automation edge case already, and low enough that a tab open for a day
/// cannot collect hundreds of arrays of detached DOM nodes.
const MAX_CACHE_SLOTS_PER_PAGE: usize = 8;
#[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 INITIALIZE_INSTRUCTIONS: &str = "Donut Browser MCP server. Use tools/list to discover the browser tools. A person watches your work in Donut: call get_human_updates when you start and between steps, report_progress during longer jobs, ask_human when you are unsure, and request_human_help when a page needs a person. When a Donut tool fails in a way you cannot explain, or you need something it cannot do, tell the Donut team with send_feedback. A call refused with AGENT_PAUSED or PROFILE_HELD_BY_HUMAN means the person is in control; do not retry it until get_human_updates shows the pause or hold is gone.";
const PLAN_WITHOUT_AUTOMATION: &str = " This account's plan does not include browser automation: tools that launch, drive or read a browser are refused with PLAN_REQUIRED. Managing profiles, groups, proxies, VPNs and extensions works.";
/// Every MCP protocol revision this engine can speak, newest first.
///
/// The spec says a server MUST echo the client's requested version when it
/// supports it. Answering with our own newest regardless meant a client on an
/// older SDK compared the reply against ITS supported list, found nothing, and
/// threw "Server's protocol version is not supported", so the whole feature
/// was unreachable from any agent that had not upgraded in lockstep.
const SUPPORTED_PROTOCOL_VERSIONS: &[&str] =
&["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
/// The version to answer `initialize` with: the client's, when we speak it.
fn negotiate_protocol_version(requested: Option<&str>) -> &'static str {
match requested {
Some(asked) => SUPPORTED_PROTOCOL_VERSIONS
.iter()
.find(|known| **known == asked)
.copied()
// Not a version we know. The spec's fallback is to answer with one we
// do support and let the client decide whether it can proceed.
.unwrap_or(PROTOCOL_VERSION),
None => PROTOCOL_VERSION,
}
}
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,
/// Structured detail for errors an agent has to act on, such as the
/// candidate list behind an ambiguous locator. Absent for the rest, so the
/// wire shape of every existing error is unchanged.
#[serde(skip_serializing_if = "Option::is_none")]
data: Option<serde_json::Value>,
}
/// Surface a CDP failure to the agent with the reason intact.
///
/// The distinction matters to whoever is on the other end: "the session is
/// still provisioning" invites a retry in a few seconds, "you are signed out"
/// does not, and flattening both into `-32000: something went wrong` is how an
/// automation client ends up retrying a refusal forever.
fn cdp_error(error: CdpError) -> McpError {
McpError {
code: -32000,
message: error.to_string(),
data: None,
}
}
/// Replace the engine's plan refusal with the server's device refusal.
///
/// A desktop the server will not give a Wayfern token, because another desktop
/// on the account is the primary one, launches the engine without a token, and
/// the engine then refuses every command as "requires a paid Donut Browser
/// plan", which is false for a paying account.
fn explain_engine_refusal(mut error: McpError, device_refusal: Option<String>) -> McpError {
if error.message.contains("requires a paid Donut Browser plan") {
if let Some(reason) = device_refusal {
error.message = reason;
}
}
error
}
/// The create_profile result. A setting that failed after the profile was
/// created is named as not applied, so the agent never takes it as set.
fn created_profile_result(
name: &str,
id: &str,
not_applied: &[(&str, String)],
) -> serde_json::Value {
let mut text = format!("Profile '{name}' created (id: {id})");
if !not_applied.is_empty() {
let failures: Vec<String> = not_applied
.iter()
.map(|(field, err)| format!("{field} ({err})"))
.collect();
text.push_str(&format!(
"\nNot applied: {}. Set them again with update_profile.",
failures.join("; ")
));
}
let fields: Vec<&str> = not_applied.iter().map(|(field, _)| *field).collect();
serde_json::json!({
"content": [{ "type": "text", "text": text }],
"structuredContent": { "id": id, "not_applied": fields },
})
}
/// The proxy types the proxy tools accept. The last four are Xray share links
/// passed in `vless_uri`.
const MCP_PROXY_TYPES: [&str; 9] = [
"http",
"https",
"httpstls",
"socks4",
"socks5",
"vless",
"vmess",
"trojan",
"hysteria2",
];
/// How long a keystroke waits for its acknowledgement before moving on.
///
/// Generous enough to absorb a relayed round trip, short enough that a browser
/// which stops answering does not leave the caller typing into a socket that
/// will never reply.
const KEYSTROKE_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
struct McpSession {
initialized: bool,
/// When this session was last USED, which is what the cap evicts by.
///
/// Not creation time: a long-lived agent's session is by definition the
/// oldest, so evicting by age threw out the one client actually working and
/// kept 512 abandoned ones from browser tabs that were closed hours ago.
last_used: std::time::Instant,
/// Every `(profile_id, slot)` this session has written an interactive-element
/// snapshot to, so `end_session` can delete the page globals it left behind.
///
/// Server-side state alone was never the whole session: the snapshot lives in
/// somebody's still-open tab, holds live element references, and outlives the
/// session that made it. Forgetting the session without deleting the array
/// leaks one array per session for the life of the page.
cached_pages: HashSet<(String, String)>,
}
/// How many MCP sessions one desktop will hold at once.
///
/// The map had no bound at all: nothing evicts a session except an explicit
/// `end_session`, and no first-party client sends one, the website mints a
/// session per page load in which the customer actually clicks something, and
/// the one-shot 404 retry abandons an id and mints a replacement. That is tens
/// of kilobytes over months rather than a leak with teeth, but "grows for the
/// life of the process with no ceiling" is not a property worth keeping when a
/// bound costs this little. Far above any real client's usage, so hitting it
/// means something is wrong rather than someone is busy.
const MAX_SESSIONS: usize = 512;
struct McpServerInner {
app_handle: Option<AppHandle>,
sessions: HashMap<String, McpSession>,
}
/// What answering one JSON-RPC message produced, in transport-neutral terms.
///
/// Session validation, the notification path and the automation limiter live
/// in [`McpServer::handle_message`], and the cloud bridge in
/// [`crate::mcp_remote`] only translates this enum into whatever "no such
/// session" means on its own wire.
pub(crate) enum McpOutcome {
/// A JSON-RPC response to send back. `new_session_id` is set only by
/// `initialize`, and is the id the caller must echo on later messages.
Body {
body: serde_json::Value,
new_session_id: Option<String>,
},
/// A notification was accepted. JSON-RPC defines no reply to one.
Accepted,
/// The caller named a session this process does not have.
UnknownSession,
/// The payload was not a JSON-RPC message.
BadRequest,
/// The shared automation limiter refused the call.
RateLimited { retry_after_secs: u64 },
}
pub struct McpServer {
inner: Arc<AsyncMutex<McpServerInner>>,
/// Whether the tool engine can answer at all: set once it holds the app
/// handle every tool needs.
engine_ready: AtomicBool,
}
/// Who is asking: the MCP session a message belongs to, which decides whose
/// page state a caller is looking at. Every caller reached this machine over
/// the cloud bridge with an account credential, so tools that read the local
/// filesystem or export stored secrets are refused to all of them.
#[derive(Clone, Copy)]
pub(crate) struct McpCaller<'a> {
/// The session id echoed back by the caller, when it opened one at all.
session: Option<&'a str>,
/// The toolsets the caller connected with; `None` is every tool.
toolsets: Option<&'a crate::mcp_toolsets::Selection>,
}
/// The longest a single `type_text` call may plan to spend typing, over the
/// local REST API.
///
/// Five minutes is far past any real form field (roughly 2,000 words at the
/// default rate) and far short of the hours an unbounded string can reach.
pub(crate) const MAX_TYPING_SECONDS: f64 = 300.0;
/// The same bound over MCP.
///
/// A call over the bridge is answered with a timeout if it runs too long, so a
/// plan that would type for longer than that is not slow, it is a guaranteed
/// failure that still holds one of the eight process-wide permits for the full
/// five minutes. Sitting under that budget leaves room for the focus step, the
/// round trips and the answer itself.
const MAX_BRIDGE_TYPING_SECONDS: f64 = 80.0;
/// The longest text `type_text` will even PLAN, checked before planning starts.
///
/// `MarkovTyper::run` is superlinear, so the duration bound alone was not a
/// bound at all: the plan for a megabyte of text costs minutes of CPU to build,
/// and the refusal only arrives afterwards.
const MAX_TYPING_CHARS: usize = 4096;
/// Build the keystroke plan for `type_text`, or refuse it as too long.
///
/// Refused up front rather than typed for hours. Human typing sleeps between
/// keystrokes, `session_wpm` is floored at 10 (human_typing.rs), and the text is
/// bounded only by the 1 MiB frame cap, so at the floor a 10,000 character
/// string types for over three hours and a megabyte for a fortnight. Over the
/// bridge such a call holds one of eight PROCESS-WIDE permits the whole time, so
/// eight of them wedge remote control for the account, and the caller cannot
/// tell that apart from an outage.
///
/// Measured on the generated plan rather than the character count, so the bound
/// means the same thing at any wpm. Refusing beats truncating: a half-typed form
/// field is worse than an error that says what to do.
///
/// Separated from the sending loop so the decision can be tested for what it
/// DOES, not merely asserted to be present in the source.
fn plan_typing(
text: &str,
wpm: Option<f64>,
max_seconds: f64,
) -> Result<Vec<crate::human_typing::TypingEvent>, McpError> {
// Length FIRST, before a plan is built. `MarkovTyper::run` is superlinear -
// planning 20,000 characters took a single unit test 24 seconds of solid CPU -
// so a duration bound measured on the finished plan is defeated by the work of
// producing it: the caller still burns minutes of CPU, and over the bridge
// still holds one of eight process-wide permits, before being told no.
//
// 4,096 characters is far past any real form field and cheap to plan. The
// duration bound below still applies, because a slow wpm can exceed the time
// limit well under this many characters.
let chars = text.chars().count();
if chars > MAX_TYPING_CHARS {
return Err(McpError {
code: -32602,
message: serde_json::json!({
"code": "TYPING_TOO_LONG",
"params": {
"seconds": format!("{chars}"),
"limit": format!("{MAX_TYPING_CHARS}"),
}
})
.to_string(),
data: None,
});
}
let events = crate::human_typing::MarkovTyper::new(text, wpm).run();
let planned = events.last().map_or(0.0, |event| event.time);
if planned > max_seconds {
return Err(McpError {
code: -32602,
message: serde_json::json!({
"code": "TYPING_TOO_LONG",
"params": {
"seconds": format!("{planned:.0}"),
"limit": format!("{max_seconds:.0}"),
}
})
.to_string(),
data: None,
});
}
Ok(events)
}
/// The page-global slot one SESSION caches its interactive-element snapshot in.
///
/// Keyed by MCP session. One shared slot was not enough: the website console
/// and an agent both arrive over the bridge, as do two runs of the same agent,
/// so one caller's `get_interactive_elements` overwrote the array another had
/// just built and that caller's next `click_by_index(3)` resolved against the
/// wrong array, a wrong click on somebody's real browser, reported as a
/// successful one. Sessions are what tell two callers apart.
///
/// The session is not an `Option` here on purpose. A caller that never ran
/// `initialize` used to fall back to the literal `"anon"`, which is a SHARED
/// slot by another name: two such callers still overwrote each other's array
/// and still clicked each other's elements. Taking a `&str` means
/// no caller can reach a slot without a session at all -
/// [`require_indexed_session`] is the only way to get one, and it refuses.
///
/// A quoted JS string literal, because it is substituted into `window[...]`.
fn interactive_cache_slot(session: &str) -> String {
format!(
"'__donut_interactive_bridge_{}'",
cache_key_for_session(session)
)
}
/// Reduce a session id to a distinct, JS-safe fragment of a global's name.
///
/// Hashed rather than truncated. Replacing every character outside
/// `[A-Za-z0-9_]` and cutting at 64 was safe but NOT distinct, whatever the doc
/// claimed: `"abc-def"` and `"abc_def"` mapped to one slot, as did any two ids
/// agreeing on their first 64 characters, and a slot collision is the wrong
/// click this key exists to prevent. A hash of the WHOLE id collides only at
/// the 128-bit birthday bound, and hex is inherently safe to paste inside a JS
/// string literal, so nothing caller-supplied survives to close the quote,
/// inject an expression, or grow the evaluated script.
///
/// Deterministic: the same id yields the same slot for the life of the session,
/// which is what lets a second `click_by_index` find the array the first
/// `get_interactive_elements` built.
fn cache_key_for_session(session: &str) -> String {
let mut digest = blake3::hash(session.as_bytes()).to_hex().to_string();
// 128 bits of it. The full 256 would only make the global's name longer.
digest.truncate(32);
digest
}
/// The session an index-based tool needs, or a refusal explaining why.
///
/// Refusing is the only answer that cannot click the wrong element. The
/// alternative considered, minting a per-connection identity for a caller that
/// skipped `initialize`, keeps such a caller working, but every scheme for it
/// either shares a slot between two connections (the bug again) or invents an
/// identity the caller cannot name on its NEXT request, so its `click_by_index`
/// resolves against a slot it does not own. An index is meaningless without a
/// session anyway: it is a pointer into an array a PREVIOUS call left on the
/// page, and only the session says whose array that is. An error the agent can
/// act on ("call initialize") beats a click on somebody's real browser reported
/// as a success.
fn require_indexed_session(caller: McpCaller<'_>) -> Result<&str, McpError> {
caller.session.ok_or_else(|| McpError {
code: -32600,
message: "This tool needs an MCP session: call initialize and send the session id it \
returns on every later request. An element index only means something inside \
the session whose get_interactive_elements produced it."
.to_string(),
data: None,
})
}
/// Tools that take a caller-supplied filesystem path.
///
/// Refused over the bridge rather than sanitised: a remote caller cannot know
/// this machine's filesystem, so there is no legitimate remote use to preserve
///, which makes refusing strictly better than guessing at safe roots.
const LOCAL_PATH_TOOLS: &[&str] = &[
"add_extension",
"update_extension",
"detect_browser_profiles",
// Its path arrives nested inside `items[].source_path` rather than as a
// top-level argument, which is exactly how it stayed off this list: the
// regression test used to grep each handler for the literal "path" and a
// deserialized struct field has no such literal. It reads a caller-named
// directory off this disk and copies it into a profile, including the
// source browser's cookies and passwords, so over the bridge it is an
// arbitrary local-file read with an account credential.
"import_browser_profiles",
];
/// Tools refused over the bridge because their whole output is stored secrets.
///
/// The bridge gate is by tool, not by field. `export_proxies` exists to write
/// every proxy's password and VLESS URI out in full, which is exactly what the
/// redaction on `list_proxies` and `get_proxy` withholds from a remote caller,
/// so there is no redacted form of it worth serving. Refused with the same
/// code as the local-path tools: it is a local-only action.
const SECRET_EXPORT_TOOLS: &[&str] = &["export_proxies"];
/// Strip the fields of a stored proxy a remote caller must not see.
///
/// A bridge caller holds an account credential, not this machine. The proxy
/// password, the VLESS URI (which carries the credential inline), the dynamic
/// list URL and the change-IP link (which usually carry an API key) are secrets of the
/// machine, not of the account, and an agent needs none of them to pick a
/// proxy by id. Present values are replaced rather than removed so the shape
/// an agent has learned stays the same, and absent ones stay absent.
fn redact_proxy_secrets(proxy: &mut serde_json::Value) {
const REDACTED: &str = "[redacted]";
if let Some(settings) = proxy
.get_mut("proxy_settings")
.and_then(serde_json::Value::as_object_mut)
{
for field in ["password", "vless_uri"] {
if settings.get(field).is_some_and(|v| !v.is_null()) {
settings.insert(field.to_string(), serde_json::Value::from(REDACTED));
}
}
}
if let Some(map) = proxy.as_object_mut() {
for field in ["dynamic_proxy_url", "rotation_url"] {
if map.get(field).is_some_and(|v| !v.is_null()) {
map.insert(field.to_string(), serde_json::Value::from(REDACTED));
}
}
}
}
/// Reject a URL the browser should never be told to open on a customer's behalf.
///
/// `Page.navigate` will happily load `file:///Users/…/.ssh/id_rsa`, and
/// `get_page_content` will then hand the bytes straight back to the caller.
/// Over the CLOUD BRIDGE that is an arbitrary local-file read reachable from
/// the internet by anyone holding an account credential, which is not "control
/// your browser", and is exactly the machine this module exists to protect.
///
/// Allowed: the schemes a browser is actually asked to browse. `about:blank`
/// is permitted because it is the standard way to park a tab. Everything else,
/// including `file:`, `data:`, `blob:`, `chrome:`, `devtools:` and
/// `javascript:`, is refused.
pub(crate) fn is_navigable_url(url: &str) -> bool {
let trimmed = url.trim();
if trimmed.eq_ignore_ascii_case("about:blank") {
return true;
}
let lowered = trimmed.to_ascii_lowercase();
lowered.starts_with("http://") || lowered.starts_with("https://")
}
fn validate_navigable_url(url: &str) -> Result<(), McpError> {
if is_navigable_url(url) {
return Ok(());
}
Err(McpError {
code: -32602,
message: crate::backend_error("URL_SCHEME_NOT_ALLOWED"),
data: None,
})
}
// --- Agent surface: perception, locators, extraction, picker, humanized input --
/// The longest `pick_element` waits for a click over the local REST API.
pub(crate) const MAX_PICK_TIMEOUT_MS: u64 = 300_000;
/// How long `pick_element` waits when the caller does not say.
pub(crate) const DEFAULT_PICK_TIMEOUT_MS: u64 = 60_000;
/// The same bound over MCP.
///
/// A call over the bridge is answered with a timeout if it runs too long, so a
/// wait that would outlive it is not patience, it is a guaranteed failure that
/// still holds one of the eight process-wide permits. Same margin as typing.
/// The browser's own two-minute ceiling on `extract_structured` is cut to the
/// same budget for the same reason.
const MAX_BRIDGE_PICK_TIMEOUT_MS: u64 = 80_000;
/// How long the browser's own typing rhythm is budgeted per character.
///
/// `Vellum.inscribe` paces the keys itself, so this is a per-character ceiling
/// rather than an estimate: a text the budget refuses is one the browser would
/// have turned into a hung call.
const VELLUM_SECONDS_PER_CHAR: f64 = 0.25;
/// How long a click waits for the page it may have navigated to.
const CLICK_LOAD_TIMEOUT: Duration = Duration::from_secs(10);
/// The `TYPING_TOO_LONG` envelope, shared by both typing engines.
fn typing_too_long(seconds: f64, limit: f64) -> McpError {
McpError {
code: -32602,
message: serde_json::json!({
"code": "TYPING_TOO_LONG",
"params": {
"seconds": format!("{seconds:.0}"),
"limit": format!("{limit:.0}"),
}
})
.to_string(),
data: None,
}
}
/// The time a `Vellum.inscribe` of `text` may take, or the refusal.
///
/// Decided BEFORE anything touches the page, for the same reason `plan_typing`
/// is: a refusal that arrives after the field was emptied is a lie.
fn vellum_typing_budget(text: &str, max_seconds: f64) -> Result<Duration, McpError> {
let chars = text.chars().count();
if chars > MAX_TYPING_CHARS {
return Err(typing_too_long(chars as f64, MAX_TYPING_CHARS as f64));
}
let estimate = chars as f64 * VELLUM_SECONDS_PER_CHAR + 1.0;
if estimate > max_seconds {
return Err(typing_too_long(estimate, max_seconds));
}
Ok(Duration::from_secs_f64(max_seconds))
}
/// Why an agent operation could not be completed.
///
/// One vocabulary for both front doors. The MCP transport renders it as a
/// JSON-RPC error whose `data` carries the structured part, and the REST
/// transport maps the same variants onto statuses, so a locator that matched
/// three buttons is reported the same way whichever door it came through.
#[derive(Debug)]
pub(crate) enum AgentError {
/// The caller's arguments cannot mean anything on any page.
InvalidArgument(String),
/// The profile runs a Wayfern older than the feature.
RequiresWayfern152 {
version: String,
},
/// The browser refused: no paid plan behind this browser.
PaymentRequired(String),
/// The browser refused: too many calls too fast.
RateLimited(String),
/// The browser could not confirm this account's plan.
AuthorizationUnavailable(String),
AmbiguousLocator {
match_count: u64,
candidates: Vec<serde_json::Value>,
message: String,
},
NoMatch {
message: String,
},
PickerTimedOut {
timeout_ms: u64,
},
PickerCancelled {
reason: String,
},
TypingTooLong {
seconds: f64,
limit: f64,
},
/// The browser refused the request as malformed (`-32602`), or the page
/// threw at it: what was asked for does not exist or cannot be done.
BadRequest(String),
/// The browser failed to do what it was asked (`-32000`).
Browser(String),
/// The transport, or the browser's absence.
Cdp(CdpError),
/// A reply without the documented shape.
Malformed(String),
}
impl AgentError {
/// A stable code an agent can branch on.
pub(crate) fn code(&self) -> &'static str {
match self {
Self::InvalidArgument(_) => "INVALID_ARGUMENT",
Self::RequiresWayfern152 { .. } => "WAYFERN_152_REQUIRED",
Self::PaymentRequired(_) => "PAYMENT_REQUIRED",
Self::RateLimited(_) => "RATE_LIMITED",
Self::AuthorizationUnavailable(_) => "AUTHORIZATION_UNAVAILABLE",
Self::AmbiguousLocator { .. } => "LOCATOR_AMBIGUOUS",
Self::NoMatch { .. } => "LOCATOR_NO_MATCH",
Self::PickerTimedOut { .. } => "PICKER_TIMEOUT",
Self::PickerCancelled { .. } => "PICKER_CANCELLED",
Self::TypingTooLong { .. } => "TYPING_TOO_LONG",
Self::BadRequest(_) => "BAD_REQUEST",
Self::Browser(_) => "BROWSER_ERROR",
Self::Cdp(_) => "CDP_ERROR",
Self::Malformed(_) => "MALFORMED_REPLY",
}
}
/// The human-readable part.
pub(crate) fn message(&self) -> String {
match self {
Self::InvalidArgument(m)
| Self::PaymentRequired(m)
| Self::RateLimited(m)
| Self::AuthorizationUnavailable(m)
| Self::BadRequest(m)
| Self::Browser(m)
| Self::Malformed(m) => m.clone(),
Self::RequiresWayfern152 { version } => format!(
"This tool requires Wayfern 152 or newer; the profile runs {version}. Update the profile's browser, or use the selector and index tools."
),
Self::AmbiguousLocator { message, .. } | Self::NoMatch { message } => message.clone(),
Self::PickerTimedOut { timeout_ms } => {
format!("No element was picked within {timeout_ms} ms; the picker has been disarmed")
}
Self::PickerCancelled { reason } => format!("The element picker was cancelled ({reason})"),
Self::TypingTooLong { seconds, limit } => {
format!("Typing this text would take about {seconds:.0}s, over the {limit:.0}s limit")
}
Self::Cdp(e) => e.to_string(),
}
}
/// The structured part, when there is one.
pub(crate) fn detail(&self) -> serde_json::Value {
let mut detail = serde_json::json!({ "code": self.code() });
match self {
Self::AmbiguousLocator {
match_count,
candidates,
..
} => {
detail["matchCount"] = serde_json::Value::from(*match_count);
detail["candidates"] = serde_json::Value::Array(candidates.clone());
}
Self::NoMatch { .. } => {
detail["matchCount"] = serde_json::Value::from(0);
detail["candidates"] = serde_json::json!([]);
}
Self::RequiresWayfern152 { version } => {
detail["version"] = serde_json::Value::from(version.as_str());
}
Self::PickerTimedOut { timeout_ms } => {
detail["timeoutMs"] = serde_json::Value::from(*timeout_ms);
}
Self::PickerCancelled { reason } => {
detail["reason"] = serde_json::Value::from(reason.as_str());
}
Self::TypingTooLong { seconds, limit } => {
detail["seconds"] = serde_json::Value::from(*seconds);
detail["limit"] = serde_json::Value::from(*limit);
}
_ => {}
}
detail
}
/// As a JSON-RPC error. Argument problems are `-32602`; the rest is the
/// same `-32000` every other browser tool answers with, with `data` telling
/// the two apart.
fn into_mcp(self) -> McpError {
let code = match self {
Self::InvalidArgument(_) | Self::TypingTooLong { .. } | Self::BadRequest(_) => -32602,
_ => -32000,
};
let message = match &self {
// The same envelope `plan_typing` answers with, so a client that already
// understands one typing refusal understands both.
Self::TypingTooLong { seconds, limit } => typing_too_long(*seconds, *limit).message,
_ => self.message(),
};
McpError {
code,
message,
data: Some(self.detail()),
}
}
}
impl From<CdpError> for AgentError {
fn from(error: CdpError) -> Self {
if let Some(refusal) = wayfern_cdp::classify_refusal(&error) {
let message = wayfern_cdp::protocol_message(&error).unwrap_or_else(|| error.to_string());
return match refusal {
wayfern_cdp::BrowserRefusal::PaymentRequired => Self::PaymentRequired(message),
wayfern_cdp::BrowserRefusal::RateLimited => Self::RateLimited(message),
wayfern_cdp::BrowserRefusal::AuthorizationUnavailable => {
Self::AuthorizationUnavailable(message)
}
};
}
match wayfern_cdp::protocol_code(&error) {
Some(-32602) => {
Self::BadRequest(wayfern_cdp::protocol_message(&error).unwrap_or_else(|| error.to_string()))
}
Some(_) => {
Self::Browser(wayfern_cdp::protocol_message(&error).unwrap_or_else(|| error.to_string()))
}
None => Self::Cdp(error),
}
}
}
impl From<WayfernError> for AgentError {
fn from(error: WayfernError) -> Self {
match error {
WayfernError::Cdp(e) => Self::from(e),
WayfernError::AmbiguousLocator {
match_count,
candidates,
message,
} => Self::AmbiguousLocator {
match_count,
candidates,
message,
},
WayfernError::NoMatch { message } => Self::NoMatch { message },
WayfernError::PickerTimedOut { timeout_ms } => Self::PickerTimedOut { timeout_ms },
WayfernError::PickerCancelled { reason } => Self::PickerCancelled { reason },
WayfernError::Malformed(m) => Self::Malformed(m),
}
}
}
impl From<McpError> for AgentError {
/// The typing planner answers in the `TYPING_TOO_LONG` envelope; anything
/// else from that layer is a browser-side failure.
fn from(error: McpError) -> Self {
if let Ok(envelope) = serde_json::from_str::<serde_json::Value>(&error.message) {
if envelope.get("code").and_then(|c| c.as_str()) == Some("TYPING_TOO_LONG") {
let number = |key: &str| {
envelope["params"][key]
.as_str()
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0)
};
return Self::TypingTooLong {
seconds: number("seconds"),
limit: number("limit"),
};
}
}
Self::Browser(error.message)
}
}
/// A running browser, and the engine its version entitles it to.
pub(crate) struct AgentContext {
pub profile: BrowserProfile,
pub target: CdpTarget,
pub engine: Engine,
}
impl AgentContext {
/// The engine is read from the profile HERE, at the point of use, never
/// cached: a profile updated to 152 gets the native path on its next call.
pub(crate) fn new(profile: BrowserProfile, target: CdpTarget) -> Self {
let engine = Engine::for_version(&profile.version);
Self {
profile,
target,
engine,
}
}
fn requires_wayfern_152(&self) -> Result<(), AgentError> {
if self.engine.is_wayfern() {
Ok(())
} else {
Err(AgentError::RequiresWayfern152 {
version: self.profile.version.clone(),
})
}
}
}
/// The body of `resolve_locator`.
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub(crate) struct AgentResolveRequest {
pub locator: LocatorDescription,
/// How many candidates an ambiguity error lists. Default 10, ceiling 100.
#[serde(default, alias = "candidateLimit")]
pub candidate_limit: Option<u64>,
}
/// The body of `click_locator`.
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub(crate) struct AgentClickRequest {
pub locator: LocatorDescription,
/// "left" (default), "middle", "right", "back" or "forward".
#[serde(default)]
pub button: Option<String>,
/// 1 (default), 2 for a double click, 3 for a triple.
#[serde(default, alias = "clickCount")]
pub click_count: Option<u32>,
}
/// What `click_locator` did.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub(crate) struct AgentClick {
pub clicked: bool,
#[serde(rename = "match")]
pub matched: LocatorCandidate,
pub engine: Engine,
/// Whether a page load followed the click.
pub navigated: bool,
}
/// The body of `type_locator`.
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub(crate) struct AgentTypeRequest {
pub locator: LocatorDescription,
pub text: String,
/// Empty the field first. Default true.
#[serde(default, alias = "clearFirst")]
pub clear_first: Option<bool>,
/// Mistype and correct a few characters, as a hand does. Default true.
#[serde(default)]
pub typos: Option<bool>,
/// Target words per minute. Honoured by the fallback engine only: Wayfern
/// 152 types at the profile's own rhythm.
#[serde(default)]
pub wpm: Option<f64>,
}
/// What `type_locator` did.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub(crate) struct AgentTyping {
pub typed: bool,
/// Characters delivered.
pub characters: u64,
/// Mistyped characters that were corrected. Absent on the fallback engine,
/// which does not count its own.
#[serde(skip_serializing_if = "Option::is_none")]
pub corrections: Option<u64>,
pub duration_ms: f64,
pub engine: Engine,
#[serde(rename = "match")]
pub matched: LocatorCandidate,
}
/// The body of `pick_element`.
#[derive(Debug, Clone, Default, Deserialize, ToSchema)]
pub(crate) struct AgentPickRequest {
/// How long to wait for the click. Default 60000, ceiling 300000.
#[serde(default, alias = "timeoutMs")]
pub timeout_ms: Option<u64>,
}
/// Where a locator refers to, spelled the way the browser spells it in its
/// own error messages.
fn describe_locator(locator: &LocatorDescription) -> String {
let mut parts = Vec::new();
if let Some(role) = &locator.role {
parts.push(format!("role={role}"));
}
if let Some(name) = &locator.name {
parts.push(format!("name={name}"));
}
if let Some(name) = &locator.name_contains {
parts.push(format!("nameContains={name}"));
}
if let Some(text) = &locator.text {
parts.push(format!("text={text}"));
}
if let Some(text) = &locator.text_contains {
parts.push(format!("textContains={text}"));
}
for attribute in locator.attributes.iter().flatten() {
parts.push(format!("{}={}", attribute.name, attribute.value));
}
parts.join(", ")
}
/// ARIA role names for the Blink AX tokens the browser indexes.
///
/// The perception snapshot and the locator resolver speak Blink's own role
/// vocabulary (`textField`, `staticText`, `radioButton`), and so does the
/// fallback engine, so the two agree. An agent that has read ARIA writes
/// `textbox`. The browser matches roles case- and separator-insensitively, so
/// only the tokens that differ in substance are translated here, and only
/// where the ARIA role has exactly one Blink counterpart.
const ROLE_SYNONYMS: [(&str, &str); 7] = [
("textbox", "textField"),
("radio", "radioButton"),
("progressbar", "progressIndicator"),
("separator", "splitter"),
("generic", "genericContainer"),
("text", "staticText"),
("img", "image"),
];
/// The locator with its role spelled the way the browser spells it.
fn canonical_locator(locator: &LocatorDescription) -> LocatorDescription {
let mut canonical = locator.clone();
if let Some(role) = &locator.role {
let key: String = role
.chars()
.filter(char::is_ascii_alphanumeric)
.collect::<String>()
.to_ascii_lowercase();
if let Some((_, blink)) = ROLE_SYNONYMS.iter().find(|(aria, _)| *aria == key) {
canonical.role = Some((*blink).to_string());
}
}
canonical
}
fn validate_locator(locator: &LocatorDescription) -> Result<(), AgentError> {
if locator.is_empty() {
return Err(AgentError::InvalidArgument(
"locator needs at least one of role, name, nameContains, text, textContains or attributes"
.to_string(),
));
}
for attribute in locator.attributes.iter().flatten() {
if attribute.name.trim().is_empty() {
return Err(AgentError::InvalidArgument(
"every locator attribute needs a name".to_string(),
));
}
}
Ok(())
}
/// The mouse button names both engines accept.
const MOUSE_BUTTONS: [&str; 5] = ["left", "middle", "right", "back", "forward"];
fn validate_click(request: &AgentClickRequest) -> Result<(), AgentError> {
validate_locator(&request.locator)?;
if let Some(button) = request.button.as_deref() {
if !MOUSE_BUTTONS.contains(&button) {
return Err(AgentError::InvalidArgument(format!(
"button must be one of {}",
MOUSE_BUTTONS.join(", ")
)));
}
}
if let Some(count) = request.click_count {
if !(1..=3).contains(&count) {
return Err(AgentError::InvalidArgument(
"click_count must be 1, 2 or 3".to_string(),
));
}
}
Ok(())
}
fn validate_extraction(request: &ExtractionRequest) -> Result<(), AgentError> {
validate_locator(&request.container)?;
if request.field_map.is_empty() {
return Err(AgentError::InvalidArgument(
"field_map needs at least one field".to_string(),
));
}
for field in &request.field_map {
if field.key.trim().is_empty() {
return Err(AgentError::InvalidArgument(
"every field needs a key".to_string(),
));
}
validate_locator(&field.locator)?;
match field.source.as_str() {
"text" | "link" => {}
"attribute" => {
if field
.attribute
.as_deref()
.is_none_or(|a| a.trim().is_empty())
{
return Err(AgentError::InvalidArgument(format!(
"field '{}' reads an attribute but names none",
field.key
)));
}
}
other => {
return Err(AgentError::InvalidArgument(format!(
"field '{}' has source '{other}'; it must be text, attribute or link",
field.key
)))
}
}
}
if let Some(next) = &request.next_page {
validate_locator(next)?;
}
Ok(())
}
/// Evaluate `expression` on a fresh connection and hand back the JSON string
/// it returned, or the exception it threw as a [`AgentError::BadRequest`].
///
/// Every fallback script returns `JSON.stringify(...)`, so one reader serves
/// them all.
async fn evaluate_json_script(
target: &CdpTarget,
expression: String,
) -> Result<serde_json::Value, AgentError> {
let result = crate::cdp_target::run_command(
target,
"Runtime.evaluate",
serde_json::json!({ "expression": expression, "returnByValue": true }),
)
.await?;
parse_json_script_result(&result)
}
/// What a page script threw, as a [`AgentError::BadRequest`].
fn script_exception(result: &serde_json::Value) -> Option<AgentError> {
let exception = result.get("exceptionDetails")?;
let message = exception
.get("exception")
.and_then(|e| e.get("description"))
.or_else(|| exception.get("text"))
.and_then(|v| v.as_str())
.unwrap_or("the page script failed");
Some(AgentError::BadRequest(message.to_string()))
}
/// The same reader for a script evaluated on an existing session.
fn parse_json_script_result(result: &serde_json::Value) -> Result<serde_json::Value, AgentError> {
if let Some(thrown) = script_exception(result) {
return Err(thrown);
}
let text = result
.get("result")
.and_then(|r| r.get("value"))
.and_then(|v| v.as_str())
.ok_or_else(|| AgentError::Malformed("the page script returned no value".to_string()))?;
serde_json::from_str(text)
.map_err(|e| AgentError::Malformed(format!("the page script returned invalid JSON: {e}")))
}
/// The pre-152 stand-in for the browser's perception and locator domains, and
/// the page side of the 152 locator path.
///
/// One script, three modes, over the light DOM and every open shadow root.
/// `perceive` walks the visible DOM and describes it in the SAME node shape
/// `Wayfern.capturePagePerception` answers with, so an agent written against
/// 152 reads both. `resolve` applies a locator with the browser's own semantics
/// (role token, exact or substring name and text, every attribute pair) and
/// refuses ambiguity the same way; with `act` it also scrolls to, or focuses
/// and prepares, the one match, because the element reference cannot leave the
/// page and a second script could resolve a different node. `pin` finds the
/// element behind a match the browser described, by its attributes, role and
/// text. With `returnElement`, `resolve` and `pin` answer `[report, element]`
/// for a caller that reads the element by reference. `__OPTS__` is substituted
/// with a JSON object at call time.
const AGENT_FALLBACK_JS: &str = r#"(() => {
const opts = __OPTS__;
const now = () => (window.performance && performance.now) ? performance.now() : Date.now();
const started = now();
const norm = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
const roleKey = (r) => norm(r).toLowerCase().replace(/[^a-z0-9]/g, '');
// Blink refines a button with aria-haspopup or aria-pressed into its own
// role; a locator naming either spelling means the same control.
const roleMatchKey = (r) => { const k = roleKey(r); return k === 'popupbutton' || k === 'togglebutton' ? 'button' : k; };
// Blink's own AX role tokens, which is what Wayfern 152 answers with, so an
// agent reads one vocabulary whichever engine answered.
const INPUT_ROLES = { button: 'button', submit: 'button', reset: 'button', image: 'button', file: 'button', color: 'button', checkbox: 'checkBox', radio: 'radioButton', range: 'slider', number: 'spinButton', search: 'searchBox', hidden: 'none' };
const TAG_ROLES = { a: 'link', area: 'link', button: 'button', select: 'comboBoxSelect', textarea: 'textField', img: 'image', h1: 'heading', h2: 'heading', h3: 'heading', h4: 'heading', h5: 'heading', h6: 'heading', nav: 'navigation', main: 'main', header: 'banner', footer: 'contentInfo', form: 'form', table: 'table', tr: 'row', td: 'cell', th: 'columnHeader', ul: 'list', ol: 'list', li: 'listItem', p: 'paragraph', option: 'listBoxOption', label: 'labelText', dialog: 'dialog', section: 'region', article: 'article', aside: 'complementary', summary: 'disclosureTriangle', details: 'details', fieldset: 'group', legend: 'legend', progress: 'progressIndicator', meter: 'meter', hr: 'splitter', blockquote: 'blockquote', code: 'code', em: 'emphasis', strong: 'strong', figure: 'figure', menu: 'menu', output: 'status', time: 'time', dd: 'definition', dt: 'term', dl: 'descriptionList', caption: 'caption', video: 'video', audio: 'audio', iframe: 'iframe' };
const NAMED_FROM_CONTENT = new Set(['button', 'link', 'heading', 'tab', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'listboxoption', 'option', 'cell', 'columnheader', 'rowheader', 'gridcell', 'checkbox', 'radiobutton', 'radio', 'switch', 'treeitem', 'tooltip', 'legend', 'caption', 'labeltext', 'label', 'disclosuretriangle']);
const ATTRS = ['id', 'name', 'type', 'role', 'href', 'src', 'placeholder', 'aria-label', 'title', 'alt', 'data-testid', 'data-test', 'data-id', 'for', 'value'];
const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'HEAD', 'META', 'LINK', 'TITLE', 'BR', 'WBR']);
const vw = window.innerWidth, vh = window.innerHeight;
const sx = window.scrollX || 0, sy = window.scrollY || 0;
const fnv = (s) => { let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; } return ('00000000' + h.toString(16)).slice(-8); };
const tagOf = (el) => String(el.tagName || '').toLowerCase();
// Parents and containment cross shadow boundaries: the browser's accessibility
// tree is built over the flattened tree, so a button inside a web component is
// a child of its host here too.
const parentOf = (el) => el.parentElement || (el.parentNode && el.parentNode.host) || null;
const containsDeep = (outer, inner) => { for (let n = inner; n; n = n.parentNode || n.host) { if (n === outer) return true; } return false; };
const pathOf = (el) => { const parts = []; let n = el; while (n && n.nodeType === 1 && n !== document.documentElement) { let i = 1, s = n; while ((s = s.previousElementSibling)) { if (s.tagName === n.tagName) i++; } parts.push(tagOf(n) + ':' + i); n = parentOf(n); } return parts.reverse().join('>'); };
const roleOf = (el) => {
const explicit = el.getAttribute('role');
if (explicit && norm(explicit)) return norm(explicit).split(' ')[0];
const tag = tagOf(el);
if (tag === 'input') { const t = (el.getAttribute('type') || 'text').toLowerCase(); return INPUT_ROLES[t] || 'textField'; }
if (tag === 'a' && !el.hasAttribute('href')) return 'genericContainer';
if (el.isContentEditable && !TAG_ROLES[tag]) return 'textField';
return TAG_ROLES[tag] || 'genericContainer';
};
const isProtected = (el) => tagOf(el) === 'input' && ((el.getAttribute('type') || '').toLowerCase() === 'password' || /(^|\s)(cc-|password|one-time-code)/i.test(el.getAttribute('autocomplete') || ''));
const contentText = (el) => norm(el.innerText != null ? el.innerText : el.textContent);
// The browser's name from content also reads image alt text, which
// innerText leaves out ("User Avatar Expand user menu").
const altText = (el) => { const parts = []; const walk = (n) => { for (const c of n.childNodes) { if (c.nodeType === 3) parts.push(c.nodeValue); else if (c.nodeType === 1 && !SKIP.has(String(c.tagName).toUpperCase()) && c.getAttribute('aria-hidden') !== 'true') { const t = tagOf(c); if (t === 'img' || t === 'area' || t === 'svg') { const a = c.getAttribute('aria-label') || c.getAttribute('alt'); if (a) parts.push(a); } else walk(c); } } }; walk(el); return norm(parts.join(' ')).slice(0, 300); };
const nameOf = (el, role) => {
const ids = el.getAttribute('aria-labelledby');
if (ids) { const scope = el.getRootNode && el.getRootNode().getElementById ? el.getRootNode() : document; const t = norm(ids.split(/\s+/).map((id) => { const n = scope.getElementById(id); return n ? (n.innerText != null ? n.innerText : n.textContent) : ''; }).join(' ')); if (t) return t.slice(0, 300); }
const aria = el.getAttribute('aria-label'); if (aria && norm(aria)) return norm(aria).slice(0, 300);
if (el.labels && el.labels.length) { const t = norm(Array.from(el.labels).map((l) => l.innerText != null ? l.innerText : l.textContent).join(' ')); if (t) return t.slice(0, 300); }
const tag = tagOf(el);
if (tag === 'img' || tag === 'area') { const alt = el.getAttribute('alt'); if (alt != null && norm(alt)) return norm(alt).slice(0, 300); }
if (tag === 'input') { const t = (el.getAttribute('type') || 'text').toLowerCase(); if (t === 'button' || t === 'submit' || t === 'reset') { const v = el.getAttribute('value'); if (v && norm(v)) return norm(v).slice(0, 300); if (t === 'submit') return 'Submit'; if (t === 'reset') return 'Reset'; } }
const ph = el.getAttribute('placeholder'); if (ph && norm(ph)) return norm(ph).slice(0, 300);
const title = el.getAttribute('title'); if (title && norm(title)) return norm(title).slice(0, 300);
if (NAMED_FROM_CONTENT.has(roleKey(role)) || tag === 'summary') return contentText(el).slice(0, 300);
return '';
};
const namesOf = (el, role) => { const name = nameOf(el, role); if (!NAMED_FROM_CONTENT.has(roleKey(role)) || el.hasAttribute('aria-label') || el.hasAttribute('aria-labelledby')) return [name]; const alt = altText(el); return alt && alt !== name ? [name, alt] : [name]; };
const liveValue = (el) => { const tag = tagOf(el); if (tag === 'input' || tag === 'textarea') return String(el.value); if (tag === 'select') { const o = el.options[el.selectedIndex]; return o ? norm(o.text) : ''; } return null; };
const attrsOf = (el) => { const out = []; for (const a of ATTRS) { if (a === 'value') { if (isProtected(el)) continue; const v = liveValue(el); if (v !== null) { if (norm(v)) out.push({ name: a, value: norm(v).slice(0, 200) }); continue; } } const v = el.getAttribute(a); if (v != null && norm(v)) out.push({ name: a, value: norm(v).slice(0, 200) }); } return out; };
const attrValue = (el, name) => { if (name === 'value') { if (isProtected(el)) return null; const v = liveValue(el); if (v !== null) return v; } const v = el.getAttribute(name); return v == null ? null : v; };
const urlOf = (el) => { const tag = tagOf(el); if (tag === 'a' || tag === 'area') return el.href || undefined; if (tag === 'img') return el.currentSrc || el.src || undefined; return undefined; };
const visibleRect = (el) => { if (SKIP.has(String(el.tagName).toUpperCase())) return null; const style = window.getComputedStyle(el); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return null; const r = el.getBoundingClientRect(); if (!(r.width > 0 && r.height > 0)) return null; return r; };
const describe = (el, r) => {
const role = roleOf(el); const name = nameOf(el, role);
const out = { role, name, text: contentText(el).slice(0, 2000), signature: 'js-' + fnv(tagOf(el) + '|' + role + '|' + name + '|' + pathOf(el)), attributes: attrsOf(el), bounds: { x: r.left + sx, y: r.top + sy, width: r.width, height: r.height } };
if (!isProtected(el)) { const v = liveValue(el); if (v !== null) out.value = v; }
const u = urlOf(el); if (u) out.url = u;
return out;
};
const clampCenter = (r) => { const left = Math.max(r.left, 0), top = Math.max(r.top, 0), right = Math.min(r.right, vw), bottom = Math.min(r.bottom, vh); if (right <= left || bottom <= top) return { x: r.left + r.width / 2, y: r.top + r.height / 2, width: r.width, height: r.height, visible: false }; return { x: left + (right - left) / 2, y: top + (bottom - top) / 2, width: right - left, height: bottom - top, visible: true }; };
// Document order, with each open shadow tree right after its host.
const collect = (root, out) => { for (const el of root.querySelectorAll('*')) { out.push(el); if (el.shadowRoot) collect(el.shadowRoot, out); } return out; };
const all = collect(document, []);
const answer = (report, el) => opts.returnElement ? [JSON.stringify(report), el || null] : JSON.stringify(report);
if (opts.mode === 'pin') {
const want = opts.match || {};
const skip = new Set((opts.skipAttributes || []).map((n) => String(n).toLowerCase()));
const bagOf = (pairs) => pairs.filter((a) => !skip.has(String(a.name).toLowerCase())).map((a) => String(a.name).toLowerCase() + '=' + a.value).sort().join('\n');
const wanted = bagOf(Array.isArray(want.attributes) ? want.attributes : []);
const squash = (s) => norm(s).replace(/ /g, '');
let found = all.filter((el) => visibleRect(el) && bagOf(Array.from(el.attributes)) === wanted);
// Each step narrows only when something survives it: the role and text the
// browser computed can differ from this script's in spacing or token.
const narrow = (keep) => { if (found.length > 1) { const kept = found.filter(keep); if (kept.length) found = kept; } };
if (want.role) narrow((el) => roleMatchKey(roleOf(el)) === roleMatchKey(want.role));
if (want.text) narrow((el) => squash(contentText(el)) === squash(want.text));
if (want.name) narrow((el) => namesOf(el, roleOf(el)).includes(norm(want.name)));
narrow((el) => !found.some((o) => o !== el && containsDeep(el, o)));
return answer({ matchCount: found.length }, found.length === 1 ? found[0] : null);
}
if (opts.mode === 'resolve') {
const L = opts.locator || {};
const roleWanted = L.role != null ? roleMatchKey(L.role) : null;
const attrs = Array.isArray(L.attributes) ? L.attributes : [];
const matches = [];
for (const el of all) {
const r = visibleRect(el); if (!r) continue;
const role = roleOf(el);
if (roleWanted !== null && roleMatchKey(role) !== roleWanted) continue;
if (L.name != null || L.nameContains != null) {
const names = namesOf(el, role);
if (L.name != null && !names.includes(norm(L.name))) continue;
if (L.nameContains != null && !names.some((name) => name.includes(norm(L.nameContains)))) continue;
}
if (L.text != null || L.textContains != null) {
const text = contentText(el);
if (L.text != null && text !== norm(L.text)) continue;
if (L.textContains != null && !text.includes(norm(L.textContains))) continue;
}
let ok = true;
for (const a of attrs) { if (!a || typeof a.name !== 'string' || attrValue(el, a.name) !== String(a.value)) { ok = false; break; } }
if (!ok) continue;
matches.push({ el, r });
}
// A wrapper matches a text locator only because its child does; the
// innermost of nested matches is the element the caller meant.
const kept = matches.filter((m) => !matches.some((o) => o !== m && m.el !== o.el && containsDeep(m.el, o.el)));
const limit = Math.max(1, Math.min(100, Number(opts.candidateLimit) || 10));
const result = { matchCount: kept.length, candidates: kept.slice(0, limit).map((m) => describe(m.el, m.r)) };
if (kept.length === 1) {
const el = kept[0].el;
if (opts.act === 'scroll' || opts.act === 'focus') {
try { el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' }); } catch (e) { el.scrollIntoView(); }
}
if (opts.act === 'focus') {
el.focus();
const editable = !!el.isContentEditable; const tag = tagOf(el);
if (opts.clearFirst) {
if (editable) { el.textContent = ''; } else if (tag === 'input' || tag === 'textarea') { el.value = ''; }
el.dispatchEvent(new Event('input', { bubbles: true }));
} else if (editable) {
const sel = window.getSelection(); if (sel) { sel.selectAllChildren(el); sel.collapseToEnd(); }
} else if (typeof el.setSelectionRange === 'function') {
try { const n = String(el.value || '').length; el.setSelectionRange(n, n); } catch (e) {}
}
}
result.match = describe(el, el.getBoundingClientRect());
result.center = clampCenter(el.getBoundingClientRect());
}
return answer(result, kept.length === 1 ? kept[0].el : null);
}
const maxNodes = Number(opts.maxNodes) > 0 ? Number(opts.maxNodes) : 100000;
const maxBytes = Number(opts.maxBytes) > 0 ? Number(opts.maxBytes) : 1048576;
const includeText = opts.includeText !== false;
const ids = new Map();
const scrollables = new Set();
const nodes = [];
let total = 0;
let truncated = false;
for (const el of all) {
const r = visibleRect(el); if (!r) continue;
total++;
const role = roleOf(el);
if (role === 'none' || role === 'presentation') continue;
const own = norm(Array.from(el.childNodes).filter((n) => n.nodeType === 3).map((n) => n.nodeValue).join(' '));
if (role === 'genericContainer' && !own) continue;
const inViewport = r.right > 0 && r.bottom > 0 && r.left < vw && r.top < vh;
if (opts.viewportOnly && !inViewport) continue;
if (nodes.length >= maxNodes) { truncated = true; break; }
const id = 'n' + nodes.length;
ids.set(el, id);
const node = { id, frameId: 'f0', role, x: r.left + sx, y: r.top + sy, width: r.width, height: r.height, inViewport, visible: true, focused: document.activeElement === el, disabled: !!el.disabled || el.getAttribute('aria-disabled') === 'true' };
for (let p = parentOf(el); p; p = parentOf(p)) { const pid = ids.get(p); if (pid) { node.parentId = pid; if (scrollables.has(pid)) node.scrollContainerId = pid; break; } }
if (!node.scrollContainerId) { for (let p = parentOf(el); p; p = parentOf(p)) { const pid = ids.get(p); if (pid && scrollables.has(pid)) { node.scrollContainerId = pid; break; } } }
const name = nameOf(el, role); if (name) node.name = name;
if (own && includeText) node.text = own.slice(0, 2000);
if (!isProtected(el)) { const v = liveValue(el); if (v !== null && v !== '') node.value = v.slice(0, 500); }
const tag = tagOf(el);
const rk = roleKey(role);
if (rk === 'checkbox' || rk === 'radiobutton' || rk === 'radio' || rk === 'switch' || rk === 'menuitemcheckbox' || rk === 'menuitemradio') { const ac = el.getAttribute('aria-checked'); node.checked = ac != null ? ac : (el.indeterminate ? 'mixed' : (el.checked ? 'true' : 'false')); }
const ae = el.getAttribute('aria-expanded');
if (ae != null) node.expanded = ae === 'true'; else if (tag === 'summary' && el.parentElement && tagOf(el.parentElement) === 'details') node.expanded = !!el.parentElement.open;
const style = window.getComputedStyle(el); const oy = style.overflowY, ox = style.overflowX;
if (((oy === 'auto' || oy === 'scroll') && el.scrollHeight > el.clientHeight + 1) || ((ox === 'auto' || ox === 'scroll') && el.scrollWidth > el.clientWidth + 1)) { node.scrollable = true; scrollables.add(id); }
nodes.push(node);
}
let text = includeText ? norm(document.body ? document.body.innerText : '') : '';
let bytes = JSON.stringify(nodes).length + text.length;
if (bytes > maxBytes) {
truncated = true;
if (text.length > maxBytes / 2) text = text.slice(0, Math.floor(maxBytes / 2));
while (nodes.length && JSON.stringify(nodes).length + text.length > maxBytes) nodes.splice(Math.floor(nodes.length * 0.9));
bytes = JSON.stringify(nodes).length + text.length;
}
const frames = [{ frameId: 'f0', url: location.href, crossOrigin: false }];
let framesFailed = 0;
document.querySelectorAll('iframe, frame').forEach((f, i) => { let cross = true; try { cross = !f.contentDocument; } catch (e) { cross = true; } frames.push({ frameId: 'f' + (i + 1), url: f.src || '', crossOrigin: cross, parentFrameId: 'f0' }); framesFailed++; });
return JSON.stringify({ nodes, frames, text, truncated, stats: { totalNodes: total, returnedNodes: nodes.length, bytes, elapsedMs: Math.round(now() - started), framesVisited: 1, framesFailed } });
})()"#;
/// The fallback script with its options substituted.
fn fallback_script(opts: &serde_json::Value) -> String {
AGENT_FALLBACK_JS.replace("__OPTS__", &opts.to_string())
}
/// What the fallback perception script answers with, before it is wrapped.
#[derive(Debug, Deserialize)]
struct FallbackPerception {
nodes: Vec<PerceptionNode>,
frames: Vec<PerceptionFrame>,
text: String,
truncated: bool,
stats: PerceptionStats,
}
/// Where the fallback resolver left the one match, in viewport pixels.
#[derive(Debug, Clone, Copy, Deserialize)]
struct FallbackCenter {
x: f64,
y: f64,
width: f64,
height: f64,
visible: bool,
}
/// What the fallback resolver answers with.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct FallbackResolution {
match_count: u64,
#[serde(default)]
candidates: Vec<serde_json::Value>,
#[serde(rename = "match")]
matched: Option<LocatorCandidate>,
center: Option<FallbackCenter>,
}
/// What to do to the one match, once the fallback resolver has found it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FallbackAct {
/// Describe it and nothing more.
Describe,
/// Scroll it into view, for a click.
Scroll,
/// Scroll, focus and prepare the field, for typing.
Focus { clear_first: bool },
}
/// Resolve `locator` in the page with the fallback script.
///
/// Refuses ambiguity and absence in the same structured form the native
/// resolver does, so a caller sees one contract whichever engine answered.
async fn fallback_resolve(
target: &CdpTarget,
locator: &LocatorDescription,
candidate_limit: Option<u64>,
act: FallbackAct,
) -> Result<(LocatorCandidate, Option<FallbackCenter>), AgentError> {
let (act_name, clear_first) = match act {
FallbackAct::Describe => ("none", false),
FallbackAct::Scroll => ("scroll", false),
FallbackAct::Focus { clear_first } => ("focus", clear_first),
};
let opts = serde_json::json!({
"mode": "resolve",
"locator": locator,
"candidateLimit": candidate_limit.unwrap_or(10).clamp(1, 100),
"act": act_name,
"clearFirst": clear_first,
});
let answer = evaluate_json_script(target, fallback_script(&opts)).await?;
let resolution: FallbackResolution = serde_json::from_value(answer)
.map_err(|e| AgentError::Malformed(format!("fallback resolver: {e}")))?;
let center = resolution.center;
Ok((settle_fallback(resolution, locator)?, center))
}
/// The one match a fallback resolution found, or its refusal.
fn settle_fallback(
resolution: FallbackResolution,
locator: &LocatorDescription,
) -> Result<LocatorCandidate, AgentError> {
match resolution.match_count {
0 => Err(AgentError::NoMatch {
message: format!("No node matches locator ({}).", describe_locator(locator)),
}),
1 => resolution
.matched
.ok_or_else(|| AgentError::Malformed("fallback resolver answered one match without it".into())),
count => Err(AgentError::AmbiguousLocator {
match_count: count,
message: format!(
"Ambiguous locator: {count} nodes match. Refine it with a role, a stable attribute, or more exact text. Candidates: {}",
serde_json::Value::Array(resolution.candidates.clone())
),
candidates: resolution.candidates,
}),
}
}
/// Attributes left out when a matched node is compared with a DOM element.
///
/// The browser's index never carries `id` and `class` (Blink serializes them
/// apart from the other HTML attributes), withholds `value` on a protected
/// field, and reads `style` without the update the CSSOM leaves pending.
const UNCOMPARED_ATTRIBUTES: [&str; 4] = ["id", "class", "style", "value"];
/// Attribute pairs as a set that compares equal between the browser's index
/// and the DOM.
fn attribute_bag<'a>(pairs: impl Iterator<Item = (&'a str, &'a str)>) -> Vec<(String, &'a str)> {
let mut bag: Vec<(String, &str)> = pairs
.map(|(name, value)| (name.to_ascii_lowercase(), value))
.filter(|(name, _)| !UNCOMPARED_ATTRIBUTES.contains(&name.as_str()))
.collect();
bag.sort();
bag
}
/// A locator settled on one element of a Wayfern 152 page.
struct LocatedNode {
/// The id the DOM domain knows the element by.
backend_node_id: i64,
matched: LocatorCandidate,
/// Wayfern when the browser's resolver matched, Fallback when the page did.
engine: Engine,
}
/// Text and whitespace-free text, the way both sides compare it: the browser
/// joins text runs with spaces where the DOM may have none.
fn squashed(text: &str) -> String {
text.chars().filter(|c| !c.is_whitespace()).collect()
}
/// The text under a `DOM.describeNode` subtree, without script and style.
fn described_text(node: &serde_json::Value, out: &mut String) {
if node.get("nodeType").and_then(|t| t.as_i64()) == Some(3) {
out.push_str(node.get("nodeValue").and_then(|v| v.as_str()).unwrap_or(""));
return;
}
if matches!(
node.get("nodeName").and_then(|n| n.as_str()),
Some("SCRIPT" | "STYLE" | "TEMPLATE")
) {
return;
}
for key in ["shadowRoots", "children"] {
for child in node
.get(key)
.and_then(|c| c.as_array())
.into_iter()
.flatten()
{
described_text(child, out);
}
}
}
/// The browser's node id for `matched`, when the DOM shows that node carries
/// the attributes and the text the browser matched.
///
/// Attributes alone are not enough: a renumbered id can land on a sibling with
/// the same markup. A match with no comparable attribute, or text deeper than
/// the subtree read here, is left to [`page_element`].
async fn confirmed_node_id(
session: &mut WayfernSession,
matched: &LocatorCandidate,
) -> Option<i64> {
let id = matched.backend_node_id?;
let wanted = attribute_bag(
matched
.attributes
.iter()
.map(|a| (a.name.as_str(), a.value.as_str())),
);
if wanted.is_empty() {
return None;
}
let reply = session
.call(
"DOM.describeNode",
serde_json::json!({ "backendNodeId": id, "depth": 4, "pierce": true }),
)
.await
.ok()?;
let node = reply.get("node")?;
if node.get("nodeType").and_then(|t| t.as_i64()) != Some(1) {
return None;
}
let flat: Vec<&str> = node
.get("attributes")?
.as_array()?
.iter()
.filter_map(|v| v.as_str())
.collect();
let actual = attribute_bag(
flat
.as_chunks::<2>()
.0
.iter()
.map(|[name, value]| (*name, *value)),
);
let mut text = String::new();
described_text(node, &mut text);
(actual == wanted && squashed(&text) == squashed(&matched.text)).then_some(id)
}
/// Run the fallback script with `returnElement` on an open session: its report,
/// and the backend node id of the element it settled on, if it settled on one.
async fn page_element(
session: &mut WayfernSession,
opts: &serde_json::Value,
) -> Result<(serde_json::Value, Option<i64>), AgentError> {
const GROUP: &str = "donut-locator";
let outcome = async {
let result = session
.call(
"Runtime.evaluate",
serde_json::json!({
"expression": fallback_script(opts),
"returnByValue": false,
"objectGroup": GROUP,
}),
)
.await?;
if let Some(thrown) = script_exception(&result) {
return Err(thrown);
}
let pair = result
.pointer("/result/objectId")
.and_then(|v| v.as_str())
.ok_or_else(|| AgentError::Malformed("the page script returned no pair".into()))?;
let properties = session
.call(
"Runtime.getProperties",
serde_json::json!({ "objectId": pair, "ownProperties": true }),
)
.await?;
let entry = |index: &str| {
properties
.get("result")
.and_then(|r| r.as_array())
.and_then(|list| list.iter().find(|p| p["name"] == index))
.and_then(|p| p.get("value"))
.cloned()
};
let report = entry("0")
.as_ref()
.and_then(|v| v.get("value"))
.and_then(|v| v.as_str())
.and_then(|text| serde_json::from_str(text).ok())
.ok_or_else(|| AgentError::Malformed("the page script returned no report".into()))?;
let element = entry("1")
.filter(|v| v["subtype"] == "node")
.and_then(|v| v["objectId"].as_str().map(str::to_string));
let backend_node_id = match element {
Some(object_id) => session
.call(
"DOM.describeNode",
serde_json::json!({ "objectId": object_id }),
)
.await?
.pointer("/node/backendNodeId")
.and_then(|v| v.as_i64()),
None => None,
};
Ok((report, backend_node_id))
}
.await;
let _ = session
.call(
"Runtime.releaseObjectGroup",
serde_json::json!({ "objectGroup": GROUP }),
)
.await;
outcome
}
/// Resolve `locator` to one DOM node on a Wayfern 152 page.
///
/// The browser's resolver decides what matches. Wayfern 152 to 155 report the
/// wrong node id for that match: the index reads its snapshot through
/// Chromium's frame combiner, which renumbers every node, so the id names some
/// other node (a Google result link came back as a `<script>`). The id is used
/// when the DOM confirms it; otherwise the page finds the element the browser
/// described. When the browser matches nothing, the page applies the locator
/// itself: the index never carries `id` or `class`, and a large page can outrun
/// its snapshot budget.
async fn wayfern_locate(
session: &mut WayfernSession,
locator: &LocatorDescription,
candidate_limit: Option<u64>,
) -> Result<LocatedNode, AgentError> {
let options = ResolveOptions {
candidate_limit,
..Default::default()
};
match wayfern_cdp::resolve_locator(session, locator, options).await {
Ok(resolution) => {
let mut matched = resolution.matched;
let backend_node_id = match confirmed_node_id(session, &matched).await {
Some(id) => Some(id),
None => {
let opts = serde_json::json!({
"mode": "pin",
"match": &matched,
"skipAttributes": UNCOMPARED_ATTRIBUTES,
"returnElement": true,
});
page_element(session, &opts).await?.1
}
};
if let Some(id) = backend_node_id {
matched.backend_node_id = Some(id);
return Ok(LocatedNode {
backend_node_id: id,
matched,
engine: Engine::Wayfern,
});
}
log::debug!(
"Locator match not found in the page, applying the locator there node={}",
matched.backend_node_id.unwrap_or_default()
);
}
Err(WayfernError::NoMatch { .. }) => {}
Err(other) => return Err(other.into()),
}
page_locate(session, locator, candidate_limit).await
}
/// Resolve `locator` to one DOM node with the page's own resolver.
async fn page_locate(
session: &mut WayfernSession,
locator: &LocatorDescription,
candidate_limit: Option<u64>,
) -> Result<LocatedNode, AgentError> {
let opts = serde_json::json!({
"mode": "resolve",
"locator": locator,
"candidateLimit": candidate_limit.unwrap_or(10).clamp(1, 100),
"act": "none",
"returnElement": true,
});
let (report, backend_node_id) = page_element(session, &opts).await?;
let resolution: FallbackResolution = serde_json::from_value(report)
.map_err(|e| AgentError::Malformed(format!("fallback resolver: {e}")))?;
let mut matched = settle_fallback(resolution, locator)?;
let backend_node_id = backend_node_id
.ok_or_else(|| AgentError::Malformed("the page resolved a match it did not return".into()))?;
matched.backend_node_id = Some(backend_node_id);
Ok(LocatedNode {
backend_node_id,
matched,
engine: Engine::Fallback,
})
}
/// A point the fallback resolver left an element at, as somewhere to strike.
fn fallback_target(center: Option<FallbackCenter>) -> Result<ViewportTarget, AgentError> {
let center = center
.ok_or_else(|| AgentError::Malformed("the fallback resolver answered no position".into()))?;
if !center.visible {
return Err(AgentError::BadRequest(
"the element could not be scrolled into view; it may be hidden or clipped".to_string(),
));
}
Ok(ViewportTarget {
x: center.x,
y: center.y,
width: center.width,
height: center.height,
})
}
/// Keep a strike point inside the layout viewport.
fn clamp_to_viewport(point: ViewportTarget, viewport: (f64, f64)) -> ViewportTarget {
let (width, height) = viewport;
if width <= 0.0 || height <= 0.0 {
return point;
}
ViewportTarget {
x: point.x.clamp(1.0, (width - 1.0).max(1.0)),
y: point.y.clamp(1.0, (height - 1.0).max(1.0)),
..point
}
}
/// Glide to `point` and strike it, on an open session with `Page.enable` on,
/// waiting for a load afterwards.
async fn vellum_click_in(
session: &mut WayfernSession,
point: ViewportTarget,
button: Option<&str>,
click_count: Option<u32>,
) -> Result<(vellum::Strike, bool), AgentError> {
let viewport = wayfern_cdp::layout_viewport(session).await?;
let point = clamp_to_viewport(point, viewport);
let origin = wayfern_cdp::glide_origin(&point, viewport);
let width = Some(point.width.min(point.height));
// Owned by the gesture: the boxed future may only borrow what the gesture
// itself holds, never the caller's frame.
let button = button.map(str::to_owned);
let outcome = vellum::with_pointer(session, origin.0, origin.1, |s, p| {
Box::pin(async move {
vellum::glide(s, p, point.x, point.y, width).await?;
vellum::strike_awaiting_load(s, p, button.as_deref(), click_count, CLICK_LOAD_TIMEOUT).await
})
})
.await?;
Ok(outcome)
}
/// Turn the wheel at the viewport's centre, with the pointer gliding there
/// first, as a hand on a mouse would.
async fn vellum_wheel(target: &CdpTarget, delta_x: f64, delta_y: f64) -> Result<(), AgentError> {
let mut session = WayfernSession::open(target).await?;
let outcome = async {
let viewport = wayfern_cdp::layout_viewport(&mut session).await?;
let (x, y) = (viewport.0 / 2.0, viewport.1 / 2.0);
let centre = ViewportTarget {
x,
y,
width: viewport.0,
height: viewport.1,
};
let origin = wayfern_cdp::glide_origin(&centre, viewport);
vellum::with_pointer(&mut session, origin.0, origin.1, |s, p| {
Box::pin(async move {
vellum::glide(s, p, x, y, None).await?;
s.call(
"Input.dispatchMouseEvent",
serde_json::json!({
"type": "mouseWheel",
"x": x,
"y": y,
"deltaX": delta_x,
"deltaY": delta_y,
}),
)
.await?;
Ok::<(), WayfernError>(())
})
})
.await?;
Ok::<(), AgentError>(())
}
.await;
session.close().await;
outcome
}
/// Strike `point` with a humanized pointer on a fresh session.
async fn vellum_click(
target: &CdpTarget,
point: ViewportTarget,
button: Option<&str>,
click_count: Option<u32>,
) -> Result<(vellum::Strike, bool), AgentError> {
let mut session = WayfernSession::open(target).await?;
let outcome = async {
session.call("Page.enable", serde_json::json!({})).await?;
vellum_click_in(&mut session, point, button, click_count).await
}
.await;
let _ = session.call("Page.disable", serde_json::json!({})).await;
session.close().await;
outcome
}
/// What to do to a field between the strike that focuses it and the typing.
enum FieldPreparation {
/// Nothing: the field was already prepared by the caller's script.
None,
/// Run this script, which must return `true`.
Script(String),
/// Clear, or move the caret to the end of, the node behind this id.
Node { backend_node_id: i64, clear: bool },
}
/// Script run on a resolved node to empty it, or park the caret at its end.
///
/// A strike puts the caret where the click landed; without this a text typed
/// into a field that already holds one would land in the middle of it.
const PREPARE_FIELD_FN: &str = r#"function(clear) {
const el = this;
const editable = !!el.isContentEditable;
const tag = String(el.tagName || '').toLowerCase();
if (clear) {
if (editable) { el.textContent = ''; } else if (tag === 'input' || tag === 'textarea') { el.value = ''; }
el.dispatchEvent(new Event('input', { bubbles: true }));
} else if (editable) {
const sel = window.getSelection(); if (sel) { sel.selectAllChildren(el); sel.collapseToEnd(); }
} else if (typeof el.setSelectionRange === 'function') {
try { const n = String(el.value || '').length; el.setSelectionRange(n, n); } catch (e) {}
}
return true;
}"#;
/// Script run on the focused element to park the caret at its end.
const CARET_TO_END_JS: &str = r#"(() => {
const el = document.activeElement;
if (!el) return true;
if (el.isContentEditable) { const sel = window.getSelection(); if (sel) { sel.selectAllChildren(el); sel.collapseToEnd(); } }
else if (typeof el.setSelectionRange === 'function') { try { const n = String(el.value || '').length; el.setSelectionRange(n, n); } catch (e) {} }
return true;
})()"#;
async fn prepare_field(
session: &mut WayfernSession,
preparation: &FieldPreparation,
) -> Result<(), AgentError> {
match preparation {
FieldPreparation::None => Ok(()),
FieldPreparation::Script(script) => {
let result = session
.call(
"Runtime.evaluate",
serde_json::json!({ "expression": script, "returnByValue": true }),
)
.await?;
if let Some(exception) = result.get("exceptionDetails") {
let message = exception
.get("exception")
.and_then(|e| e.get("description"))
.or_else(|| exception.get("text"))
.and_then(|v| v.as_str())
.unwrap_or("preparing the field failed");
return Err(AgentError::BadRequest(message.to_string()));
}
Ok(())
}
FieldPreparation::Node {
backend_node_id,
clear,
} => {
let resolved = session
.call(
"DOM.resolveNode",
serde_json::json!({ "backendNodeId": backend_node_id }),
)
.await?;
let object_id = resolved
.get("object")
.and_then(|o| o.get("objectId"))
.and_then(|v| v.as_str())
.ok_or_else(|| AgentError::Malformed("DOM.resolveNode answered no object".into()))?;
session
.call(
"Runtime.callFunctionOn",
serde_json::json!({
"objectId": object_id,
"functionDeclaration": PREPARE_FIELD_FN,
"arguments": [{ "value": clear }],
"returnByValue": true,
}),
)
.await?;
Ok(())
}
}
}
/// Focus `point` with a strike, prepare the field, and type `text`.
async fn vellum_type_in(
session: &mut WayfernSession,
point: ViewportTarget,
text: &str,
typos: bool,
preparation: FieldPreparation,
timeout: Duration,
) -> Result<vellum::Inscription, AgentError> {
let viewport = wayfern_cdp::layout_viewport(session).await?;
let point = clamp_to_viewport(point, viewport);
let origin = wayfern_cdp::glide_origin(&point, viewport);
let width = Some(point.width.min(point.height));
// Owned by the gesture, for the reason `vellum_click_in` gives.
let text = text.to_owned();
let inscription = vellum::with_pointer(session, origin.0, origin.1, |s, p| {
Box::pin(async move {
vellum::glide(s, p, point.x, point.y, width).await?;
vellum::strike(s, p, None, None).await?;
prepare_field(s, &preparation).await?;
Ok::<_, AgentError>(vellum::inscribe(s, p, &text, typos, timeout).await?)
})
})
.await?;
Ok(inscription)
}
/// Type `text` at `point` on a fresh session.
async fn vellum_type(
target: &CdpTarget,
point: ViewportTarget,
text: &str,
typos: bool,
preparation: FieldPreparation,
timeout: Duration,
) -> Result<vellum::Inscription, AgentError> {
let mut session = WayfernSession::open(target).await?;
let outcome = vellum_type_in(&mut session, point, text, typos, preparation, timeout).await;
session.close().await;
outcome
}
/// Deliver an accepted keystroke plan through `Input.dispatchKeyEvent`.
///
/// The transport-level half of the pre-152 typing path, shared by the MCP
/// handlers and the REST agent endpoints.
pub(crate) async fn dispatch_keystrokes(
target: &CdpTarget,
events: &[crate::human_typing::TypingEvent],
) -> Result<(), CdpError> {
use crate::human_typing::TypingAction;
let mut connection = target.connect().await?;
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(Duration::from_secs_f64(delay)).await;
}
last_time = event.time;
let (down, up) = match &event.action {
TypingAction::Char(ch) => {
let ch = ch.to_string();
(
serde_json::json!({
"type": "keyDown",
"text": ch,
"key": ch,
"unmodifiedText": ch,
}),
serde_json::json!({ "type": "keyUp", "key": ch }),
)
}
TypingAction::Backspace => (
serde_json::json!({
"type": "keyDown",
"key": "Backspace",
"code": "Backspace",
"windowsVirtualKeyCode": 8,
"nativeVirtualKeyCode": 8,
}),
serde_json::json!({
"type": "keyUp",
"key": "Backspace",
"code": "Backspace",
"windowsVirtualKeyCode": 8,
"nativeVirtualKeyCode": 8,
}),
),
};
for params in [down, up] {
connection
.send_command(cmd_id, "Input.dispatchKeyEvent", params)
.await?;
// Drained rather than matched: the point is to keep reading so the
// browser is never writing into a full socket while the next keystroke
// is being timed. Bounded, because a reply that never comes must not
// freeze typing forever — the keystroke itself was already delivered.
let _ = tokio::time::timeout(KEYSTROKE_ACK_TIMEOUT, connection.next_text()).await;
cmd_id += 1;
}
}
connection.close().await;
Ok(())
}
/// Read the page as the agent sees it.
pub(crate) async fn agent_perceive(
ctx: &AgentContext,
request: &PerceptionRequest,
) -> Result<PerceptionPage, AgentError> {
if let Some(order) = request.text_order.as_deref() {
if order != "reading" && order != "visual" {
return Err(AgentError::InvalidArgument(
"text_order must be \"reading\" or \"visual\"".to_string(),
));
}
}
let continuation = request.cursor.as_deref().is_some_and(|c| !c.is_empty());
if ctx.engine.is_wayfern() {
let mut session = WayfernSession::open(&ctx.target).await?;
let page = wayfern_cdp::capture_page_perception(&mut session, request).await;
session.close().await;
let page = page?;
// Wayfern 152 to 155 start the budget clock before the accessibility
// snapshot, and stop the walk when it runs out without marking the result
// truncated: a heavy page came back as its header and no text. A capture
// that used its whole budget is not trusted; the DOM answers instead.
let budget = request.budget_ms.unwrap_or(5_000).clamp(100, 60_000);
if continuation || page.stats.elapsed_ms < budget {
return Ok(page);
}
log::info!(
"Perception used its budget, answering from the page profile={} budget_ms={budget} elapsed_ms={} nodes={}",
ctx.profile.id,
page.stats.elapsed_ms,
page.nodes.len()
);
} else if continuation {
return Err(AgentError::InvalidArgument(
"the fallback engine answers in one page and has no cursors to continue".to_string(),
));
}
let opts = serde_json::json!({
"mode": "perceive",
"maxNodes": request.max_nodes.unwrap_or(100_000),
"maxBytes": request.byte_cap(),
"includeText": request.include_text.unwrap_or(true),
"viewportOnly": request.viewport_only.unwrap_or(false),
});
let answer = evaluate_json_script(&ctx.target, fallback_script(&opts)).await?;
let perception: FallbackPerception = serde_json::from_value(answer)
.map_err(|e| AgentError::Malformed(format!("fallback perception: {e}")))?;
let snapshot_id = format!(
"fallback-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
);
Ok(PerceptionPage {
snapshot_id,
nodes: perception.nodes,
frames: perception.frames,
text: perception.text,
truncated: perception.truncated,
stats: perception.stats,
cursor: None,
engine: Engine::Fallback,
})
}
/// Resolve a locator to exactly one node.
pub(crate) async fn agent_resolve_locator(
ctx: &AgentContext,
request: &AgentResolveRequest,
) -> Result<LocatorResolution, AgentError> {
validate_locator(&request.locator)?;
let locator = canonical_locator(&request.locator);
if ctx.engine.is_wayfern() {
let mut session = WayfernSession::open(&ctx.target).await?;
let located = wayfern_locate(&mut session, &locator, request.candidate_limit).await;
session.close().await;
let located = located?;
return Ok(LocatorResolution {
backend_node_id: Some(located.backend_node_id),
match_count: 1,
matched: located.matched,
locator,
engine: located.engine,
});
}
let (matched, _) = fallback_resolve(
&ctx.target,
&locator,
request.candidate_limit,
FallbackAct::Describe,
)
.await?;
Ok(LocatorResolution {
backend_node_id: None,
match_count: 1,
matched,
locator,
engine: Engine::Fallback,
})
}
/// Resolve a locator and click it.
pub(crate) async fn agent_click_locator(
ctx: &AgentContext,
request: &AgentClickRequest,
) -> Result<AgentClick, AgentError> {
validate_click(request)?;
let locator = canonical_locator(&request.locator);
let button = request.button.as_deref();
if ctx.engine.is_wayfern() {
let mut session = WayfernSession::open(&ctx.target).await?;
let outcome = async {
session.call("Page.enable", serde_json::json!({})).await?;
let located = wayfern_locate(&mut session, &locator, None).await?;
let point = wayfern_cdp::viewport_target(&mut session, located.backend_node_id).await?;
let (_, navigated) =
vellum_click_in(&mut session, point, button, request.click_count).await?;
Ok::<_, AgentError>((located, navigated))
}
.await;
let _ = session.call("Page.disable", serde_json::json!({})).await;
session.close().await;
let (located, navigated) = outcome?;
return Ok(AgentClick {
clicked: true,
matched: located.matched,
engine: Engine::Wayfern,
navigated,
});
}
let (matched, center) =
fallback_resolve(&ctx.target, &locator, None, FallbackAct::Scroll).await?;
let point = fallback_target(center)?;
let mut session = WayfernSession::open(&ctx.target).await?;
let outcome = async {
session.call("Page.enable", serde_json::json!({})).await?;
session
.call(
"Input.dispatchMouseEvent",
serde_json::json!({ "type": "mouseMoved", "x": point.x, "y": point.y }),
)
.await?;
let press = serde_json::json!({
"type": "mousePressed",
"x": point.x,
"y": point.y,
"button": button.unwrap_or("left"),
"clickCount": request.click_count.unwrap_or(1),
});
session.call("Input.dispatchMouseEvent", press).await?;
let release = serde_json::json!({
"type": "mouseReleased",
"x": point.x,
"y": point.y,
"button": button.unwrap_or("left"),
"clickCount": request.click_count.unwrap_or(1),
});
let (_, navigated) = session
.call_then_await_navigation("Input.dispatchMouseEvent", release, CLICK_LOAD_TIMEOUT)
.await?;
Ok::<_, AgentError>(navigated)
}
.await;
let _ = session.call("Page.disable", serde_json::json!({})).await;
session.close().await;
Ok(AgentClick {
clicked: true,
matched,
engine: Engine::Fallback,
navigated: outcome?,
})
}
/// Resolve a locator and type into it.
///
/// `max_seconds` is the caller's typing budget; the refusal for a text that
/// would outlive it comes before anything touches the page.
pub(crate) async fn agent_type_locator(
ctx: &AgentContext,
request: &AgentTypeRequest,
max_seconds: f64,
) -> Result<AgentTyping, AgentError> {
validate_locator(&request.locator)?;
if request.text.is_empty() {
return Err(AgentError::InvalidArgument(
"text must not be empty".to_string(),
));
}
let clear_first = request.clear_first.unwrap_or(true);
let typos = request.typos.unwrap_or(true);
let locator = canonical_locator(&request.locator);
if ctx.engine.is_wayfern() {
let timeout = vellum_typing_budget(&request.text, max_seconds)?;
let mut session = WayfernSession::open(&ctx.target).await?;
let outcome = async {
let located = wayfern_locate(&mut session, &locator, None).await?;
let backend_node_id = located.backend_node_id;
let point = wayfern_cdp::viewport_target(&mut session, backend_node_id).await?;
let inscription = vellum_type_in(
&mut session,
point,
&request.text,
typos,
FieldPreparation::Node {
backend_node_id,
clear: clear_first,
},
timeout,
)
.await?;
Ok::<_, AgentError>((located, inscription))
}
.await;
session.close().await;
let (located, inscription) = outcome?;
return Ok(AgentTyping {
typed: true,
characters: inscription.characters,
corrections: Some(inscription.corrections),
duration_ms: inscription.duration_ms,
engine: Engine::Wayfern,
matched: located.matched,
});
}
// Planned first, before the field is touched, for the reason the selector
// tools plan first: a refusal after the clear is a lie.
let plan = plan_typing(&request.text, request.wpm, max_seconds)?;
let (matched, _) = fallback_resolve(
&ctx.target,
&locator,
None,
FallbackAct::Focus { clear_first },
)
.await?;
dispatch_keystrokes(&ctx.target, &plan).await?;
Ok(AgentTyping {
typed: true,
characters: request.text.chars().count() as u64,
corrections: None,
duration_ms: plan.last().map_or(0.0, |event| event.time * 1000.0),
engine: Engine::Fallback,
matched,
})
}
/// Read rows off the page. Wayfern 152 only.
pub(crate) async fn agent_extract(
ctx: &AgentContext,
request: &ExtractionRequest,
) -> Result<Extraction, AgentError> {
validate_extraction(request)?;
ctx.requires_wayfern_152()?;
let mut request = request.clone();
request.container = canonical_locator(&request.container);
for field in &mut request.field_map {
field.locator = canonical_locator(&field.locator);
}
request.next_page = request.next_page.as_ref().map(canonical_locator);
let mut session = WayfernSession::open(&ctx.target).await?;
let extraction = wayfern_cdp::extract_structured(&mut session, &request).await;
session.close().await;
Ok(extraction?)
}
/// Arm the picker and wait for the user's click. Wayfern 152 only.
pub(crate) async fn agent_pick_element(
ctx: &AgentContext,
timeout_ms: u64,
) -> Result<PickedElement, AgentError> {
ctx.requires_wayfern_152()?;
let mut session = WayfernSession::open(&ctx.target).await?;
let picked =
wayfern_cdp::pick_element(&mut session, Duration::from_millis(timeout_ms), true).await;
session.close().await;
Ok(picked?)
}
// --- Form tools: options of a select, files of a file input -----------------
/// How a form tool names its element.
enum ElementRef {
Selector(String),
/// An entry of the session's last `get_interactive_elements` listing.
Index {
cache: String,
index: u64,
},
Locator(LocatorDescription),
}
/// The object group a form tool's element handles live in, released when the
/// tool is done.
const FORM_GROUP: &str = "donut-form";
/// The page's handle to the element `element` names, and the locator's match
/// when a locator named it.
async fn form_element(
session: &mut WayfernSession,
engine: Engine,
element: &ElementRef,
) -> Result<(String, Option<LocatorCandidate>), AgentError> {
let (expression, missing) = match element {
ElementRef::Locator(locator) => {
let located = if engine.is_wayfern() {
wayfern_locate(session, locator, None).await?
} else {
page_locate(session, locator, None).await?
};
let node = session
.call(
"DOM.resolveNode",
serde_json::json!({
"backendNodeId": located.backend_node_id,
"objectGroup": FORM_GROUP,
}),
)
.await?;
let object = node
.pointer("/object/objectId")
.and_then(|v| v.as_str())
.ok_or_else(|| AgentError::Malformed("DOM.resolveNode answered no object".into()))?;
return Ok((object.to_string(), Some(located.matched)));
}
ElementRef::Selector(selector) => (
format!(
"document.querySelector({})",
serde_json::Value::from(selector.as_str())
),
format!("No element matches selector {selector}"),
),
ElementRef::Index { cache, index } => (
format!("(() => {{ const arr = window[{cache}]; return (arr && arr[{index}]) || null; }})()"),
format!(
"No element at index {index}. Call get_interactive_elements first or after navigation."
),
),
};
let result = session
.call(
"Runtime.evaluate",
serde_json::json!({
"expression": expression,
"returnByValue": false,
"objectGroup": FORM_GROUP,
}),
)
.await?;
if let Some(thrown) = script_exception(&result) {
return Err(thrown);
}
match result.pointer("/result/objectId").and_then(|v| v.as_str()) {
Some(object) => Ok((object.to_string(), None)),
None => Err(AgentError::NoMatch { message: missing }),
}
}
/// Run `function` on `object` and hand back what it returned.
async fn call_on(
session: &mut WayfernSession,
object: &str,
function: &str,
arguments: serde_json::Value,
) -> Result<serde_json::Value, AgentError> {
let reply = session
.call(
"Runtime.callFunctionOn",
serde_json::json!({
"objectId": object,
"functionDeclaration": function,
"arguments": arguments,
"returnByValue": true,
}),
)
.await?;
if let Some(thrown) = script_exception(&reply) {
return Err(thrown);
}
Ok(reply.pointer("/result/value").cloned().unwrap_or_default())
}
/// Pick the options a caller named in a `<select>` (or the one `<select>` a
/// label or a container names), the way a person's pick leaves it: focused,
/// with `input` and `change` fired when the choice changed. Answers the
/// choice, or `{ error, ... }`.
const SELECT_OPTION_FN: &str = r#"function (wanted) {
const norm = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
let el = this;
if (!(el instanceof HTMLSelectElement)) {
const inner = el.control instanceof HTMLSelectElement
? [el.control]
: Array.from(el.querySelectorAll ? el.querySelectorAll('select') : []);
if (inner.length !== 1) {
return { error: 'not_select', tag: String(el.tagName || '').toLowerCase(), found: inner.length };
}
el = inner[0];
}
const options = Array.from(el.options);
const label = (o) => norm(o.label || o.text);
const describe = (o) => ({ value: o.value, label: label(o), disabled: o.disabled });
const find = (w) => {
const text = norm(w);
return options.find((o) => o.value === w)
|| options.find((o) => label(o) === text)
|| options.find((o) => label(o).toLowerCase() === text.toLowerCase());
};
const chosen = wanted.map(find);
const missing = wanted.filter((_, i) => !chosen[i]);
if (missing.length) {
return { error: 'no_option', missing, options: options.slice(0, 50).map(describe), count: options.length };
}
if (el.disabled) return { error: 'disabled' };
const off = chosen.filter((o) => o.disabled || (o.parentElement instanceof HTMLOptGroupElement && o.parentElement.disabled));
if (off.length) return { error: 'option_disabled', options: off.map(describe) };
if (chosen.length > 1 && !el.multiple) return { error: 'single' };
const before = options.map((o) => o.selected);
el.scrollIntoView({ block: 'nearest' });
el.focus({ preventScroll: true });
if (el.multiple) {
for (const o of options) o.selected = chosen.includes(o);
} else {
el.selectedIndex = options.indexOf(chosen[0]);
}
const changed = options.some((o, i) => o.selected !== before[i]);
if (changed) {
el.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
return { selected: options.filter((o) => o.selected).map(describe), changed, multiple: el.multiple };
}"#;
/// What `select_option` did.
#[derive(Debug, Serialize)]
struct SelectedOptions {
selected: serde_json::Value,
changed: bool,
navigated: bool,
#[serde(rename = "match", skip_serializing_if = "Option::is_none")]
matched: Option<LocatorCandidate>,
}
/// A refusal `SELECT_OPTION_FN` answered, as an error an agent can act on.
fn select_refusal(answer: &serde_json::Value) -> Option<AgentError> {
let error = answer.get("error")?.as_str()?;
Some(match error {
"not_select" => AgentError::BadRequest(format!(
"The element is a <{}>, not a <select>. For a custom dropdown, click it open and click the option.",
answer["tag"].as_str().unwrap_or("?")
)),
"no_option" => AgentError::BadRequest(format!(
"No option matches {}. The select has {} options: {}",
answer["missing"], answer["count"], answer["options"]
)),
"disabled" => AgentError::BadRequest("The select is disabled.".to_string()),
"option_disabled" => AgentError::BadRequest(format!(
"These options are disabled: {}",
answer["options"]
)),
"single" => AgentError::InvalidArgument(
"This select takes one option; name one.".to_string(),
),
other => AgentError::Malformed(format!("the select script answered {other}")),
})
}
async fn agent_select_option(
ctx: &AgentContext,
element: &ElementRef,
wanted: &[String],
) -> Result<SelectedOptions, AgentError> {
let mut session = WayfernSession::open(&ctx.target).await?;
let outcome = async {
session.call("Page.enable", serde_json::json!({})).await?;
let (object, matched) = form_element(&mut session, ctx.engine, element).await?;
let (reply, navigated) = session
.call_then_await_navigation(
"Runtime.callFunctionOn",
serde_json::json!({
"objectId": object,
"functionDeclaration": SELECT_OPTION_FN,
"arguments": [{ "value": wanted }],
"returnByValue": true,
}),
CLICK_LOAD_TIMEOUT,
)
.await?;
if let Some(thrown) = script_exception(&reply) {
return Err(thrown);
}
let answer = reply.pointer("/result/value").cloned().unwrap_or_default();
if let Some(refusal) = select_refusal(&answer) {
return Err(refusal);
}
Ok(SelectedOptions {
selected: answer["selected"].clone(),
changed: answer["changed"].as_bool().unwrap_or(false),
navigated,
matched,
})
}
.await;
let _ = session
.call(
"Runtime.releaseObjectGroup",
serde_json::json!({ "objectGroup": FORM_GROUP }),
)
.await;
let _ = session.call("Page.disable", serde_json::json!({})).await;
session.close().await;
outcome
}
/// The file input `this` names: itself, the input a label is for, or the one
/// file input inside it. Answers the input, or why there is none as a string.
const FILE_INPUT_FN: &str = r#"function () {
const isFile = (n) => n instanceof HTMLInputElement && n.type === 'file';
let el = this;
if (!isFile(el)) {
if (isFile(el.control)) {
el = el.control;
} else {
const inner = el.querySelectorAll ? el.querySelectorAll('input[type=file]') : [];
if (inner.length !== 1) return inner.length ? 'many' : 'none:' + String(el.tagName || '').toLowerCase();
el = inner[0];
}
}
if (el.disabled) return 'disabled';
return el;
}"#;
/// What a file input holds now.
const INPUT_FILES_FN: &str =
"function () { return Array.from(this.files || []).map((f) => ({ name: f.name, bytes: f.size })); }";
/// What `file_upload` did.
#[derive(Debug, Serialize)]
struct UploadedFiles {
files: serde_json::Value,
#[serde(rename = "match", skip_serializing_if = "Option::is_none")]
matched: Option<LocatorCandidate>,
}
/// The files of a `file_upload` call, checked before anything touches the page.
fn upload_files(
arguments: &serde_json::Value,
) -> Result<Vec<crate::agent_uploads::UploadFile>, AgentError> {
use crate::agent_uploads::{clean_name, UploadFile, MAX_UPLOAD_BYTES, MAX_UPLOAD_FILES};
use base64::{engine::general_purpose, Engine as _};
let listed = arguments
.get("files")
.and_then(|v| v.as_array())
.filter(|files| !files.is_empty())
.ok_or_else(|| AgentError::InvalidArgument("files must list at least one file".into()))?;
if listed.len() > MAX_UPLOAD_FILES {
return Err(AgentError::InvalidArgument(format!(
"At most {MAX_UPLOAD_FILES} files per call"
)));
}
let mut files: Vec<UploadFile> = Vec::with_capacity(listed.len());
let mut total = 0usize;
for file in listed {
let raw_name = file
.get("name")
.and_then(|v| v.as_str())
.unwrap_or_default();
let name = clean_name(raw_name).ok_or_else(|| {
AgentError::InvalidArgument(format!(
"{raw_name:?} is not a usable file name; give a name like report.pdf"
))
})?;
if files.iter().any(|f| f.name.eq_ignore_ascii_case(&name)) {
return Err(AgentError::InvalidArgument(format!(
"Two files are named {name}; give each its own name"
)));
}
let bytes = match (
file.get("text").and_then(|v| v.as_str()),
file.get("content_base64").and_then(|v| v.as_str()),
) {
(Some(text), None) => text.as_bytes().to_vec(),
(None, Some(encoded)) => {
let encoded = encoded
.split_once(";base64,")
.map_or(encoded, |(_, data)| data);
let compact: String = encoded.chars().filter(|c| !c.is_whitespace()).collect();
[
general_purpose::STANDARD,
general_purpose::STANDARD_NO_PAD,
general_purpose::URL_SAFE,
general_purpose::URL_SAFE_NO_PAD,
]
.iter()
.find_map(|engine| engine.decode(&compact).ok())
.ok_or_else(|| {
AgentError::InvalidArgument(format!("content_base64 of {name} is not base64"))
})?
}
_ => {
return Err(AgentError::InvalidArgument(format!(
"Give {name} exactly one of text or content_base64"
)))
}
};
total += bytes.len();
if total > MAX_UPLOAD_BYTES {
return Err(AgentError::InvalidArgument(format!(
"The files come to more than {} KB, the most one call carries",
MAX_UPLOAD_BYTES / 1024
)));
}
files.push(UploadFile { name, bytes });
}
Ok(files)
}
async fn agent_file_upload(
ctx: &AgentContext,
element: &ElementRef,
files: &[crate::agent_uploads::UploadFile],
) -> Result<UploadedFiles, AgentError> {
let profile_id = ctx.profile.id.to_string();
let mut session = WayfernSession::open(&ctx.target).await?;
let outcome = async {
let (object, matched) = form_element(&mut session, ctx.engine, element).await?;
let reply = session
.call(
"Runtime.callFunctionOn",
serde_json::json!({
"objectId": object,
"functionDeclaration": FILE_INPUT_FN,
"returnByValue": false,
"objectGroup": FORM_GROUP,
}),
)
.await?;
if let Some(thrown) = script_exception(&reply) {
return Err(thrown);
}
let input = match reply.pointer("/result/objectId").and_then(|v| v.as_str()) {
Some(input) => input.to_string(),
None => {
let reason = reply
.pointer("/result/value")
.and_then(|v| v.as_str())
.unwrap_or_default();
return Err(AgentError::BadRequest(match reason {
"many" => "The element holds more than one file input; name the input itself, e.g. with selector input[type=file].".to_string(),
"disabled" => "The file input is disabled.".to_string(),
other => format!(
"The element is a <{}>, not a file input, and holds none. Name the input itself, e.g. with selector input[type=file]; it may be hidden.",
other.trim_start_matches("none:")
),
}));
}
};
let multiple = call_on(&mut session, &input, "function () { return this.multiple; }", serde_json::json!([]))
.await?
.as_bool()
.unwrap_or(false);
if files.len() > 1 && !multiple {
return Err(AgentError::InvalidArgument(
"This file input takes one file; send one.".to_string(),
));
}
let staged = crate::agent_uploads::stage(&profile_id, files)
.map_err(|e| AgentError::Browser(format!("Could not write the files: {e}")))?;
let staged: Vec<String> = staged
.iter()
.map(|file| file.to_string_lossy().to_string())
.collect();
session
.call(
"DOM.setFileInputFiles",
serde_json::json!({ "files": staged, "objectId": input }),
)
.await?;
let held = call_on(&mut session, &input, INPUT_FILES_FN, serde_json::json!([])).await?;
Ok(UploadedFiles {
files: held,
matched,
})
}
.await;
let _ = session
.call(
"Runtime.releaseObjectGroup",
serde_json::json!({ "objectGroup": FORM_GROUP }),
)
.await;
session.close().await;
outcome
}
/// The schema of a form tool: the four ways to name its element, and `extra`.
fn form_tool_schema(what: &str, extra: serde_json::Value, required: &[&str]) -> serde_json::Value {
let mut properties = serde_json::json!({
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"selector": {
"type": "string",
"description": format!("CSS selector of the {what}")
},
"locator": locator_schema(&format!("Instead of selector: how to name the {what}")),
"index": {
"type": "integer",
"description": "Instead of selector: zero-based index from the last get_interactive_elements response"
},
"element_id": {
"type": "string",
"description": "Instead of index: the stable id (e12) from the last get_interactive_elements response"
}
});
if let (Some(properties), Some(extra)) = (properties.as_object_mut(), extra.as_object()) {
properties.extend(extra.clone());
}
let mut required_list = vec!["profile_id"];
required_list.extend_from_slice(required);
serde_json::json!({
"type": "object",
"properties": properties,
"required": required_list
})
}
/// The JSON schema of a locator argument, shared by every tool that takes one.
fn locator_schema(description: &str) -> serde_json::Value {
serde_json::json!({
"type": "object",
"description": description,
"properties": {
"role": { "type": "string", "description": "AX role token as the browser reports it (button, link, textField, heading, listItem, checkBox, comboBoxSelect, staticText); matched case- and separator-insensitively, and the ARIA names textbox, radio, img, progressbar, separator, generic and text are accepted as synonyms" },
"name": { "type": "string", "description": "Exact accessible name, after whitespace collapse" },
"nameContains": { "type": "string", "description": "Substring of the accessible name" },
"text": { "type": "string", "description": "Exact visible text, from the live layout" },
"textContains": { "type": "string", "description": "Substring of the visible text" },
"attributes": {
"type": "array",
"description": "Attribute pairs that must all match",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"value": { "type": "string" }
},
"required": ["name", "value"]
}
}
}
})
}
/// Where a profile's browser is, as the browser tools report it.
async fn resolve_target(profile: &BrowserProfile) -> Result<CdpTarget, McpError> {
crate::cdp_target::resolve(profile)
.await
.map_err(|e| McpError {
code: -32000,
message: e.to_string(),
data: None,
})
}
/// The tail of a page script that answers where `el` is, in viewport pixels.
const RETURN_RECT_JS: &str = "const r = el.getBoundingClientRect(); return JSON.stringify({x: r.left + r.width / 2, y: r.top + r.height / 2, width: r.width, height: r.height});";
/// A script that scrolls the element behind `selector_escaped` into view and
/// answers where it is. It clicks nothing.
fn element_rect_script(selector_escaped: &str) -> String {
format!(
r#"(() => {{
const el = document.querySelector('{selector_escaped}');
if (!el) throw new Error('Element not found: {selector_escaped}');
el.scrollIntoView({{block: 'center', inline: 'center', behavior: 'instant'}});
{RETURN_RECT_JS}
}})()"#
)
}
/// The same for the element at `index` of a caller's cached snapshot.
fn indexed_rect_script(cache: &str, index: u64) -> String {
format!(
r#"(() => {{
const arr = window[{cache}];
if (!arr || !arr[{index}]) throw new Error('No element at index {index}. Call get_interactive_elements first or after navigation.');
const el = arr[{index}];
el.scrollIntoView({{block: 'center', inline: 'center', behavior: 'instant'}});
{RETURN_RECT_JS}
}})()"#
)
}
/// The strike point a rect script answered with.
fn rect_from_script_result(result: &serde_json::Value) -> Result<ViewportTarget, McpError> {
let rect = parse_json_script_result(result).map_err(AgentError::into_mcp)?;
let number = |key: &str| rect.get(key).and_then(|v| v.as_f64()).unwrap_or(0.0);
let point = ViewportTarget {
x: number("x"),
y: number("y"),
width: number("width"),
height: number("height"),
};
if !(point.width > 0.0 && point.height > 0.0) {
return Err(McpError {
code: -32000,
message: "The element has no visible box to click; it may be hidden or collapsed".to_string(),
data: None,
});
}
Ok(point)
}
/// Run a rect script and answer the strike point it found.
async fn locate_by_script(target: &CdpTarget, script: String) -> Result<ViewportTarget, McpError> {
let result = crate::cdp_target::run_command(
target,
"Runtime.evaluate",
serde_json::json!({ "expression": script, "returnByValue": true }),
)
.await
.map_err(cdp_error)?;
rect_from_script_result(&result)
}
/// What the selector and index typing tools do to the caret after the strike
/// that focuses the field.
///
/// A cleared field has nowhere else to put it. One that keeps its text needs
/// the caret at the end, or the new text lands wherever the click did.
fn caret_preparation(clear_first: bool) -> FieldPreparation {
if clear_first {
FieldPreparation::None
} else {
FieldPreparation::Script(CARET_TO_END_JS.to_string())
}
}
fn click_report(prefix: &str, navigated: bool) -> String {
if navigated {
format!("{prefix} (a page load followed)")
} else {
prefix.to_string()
}
}
/// The tools `run_actions` may run, and how many steps one call may hold.
const BATCH_TOOLS: &[&str] = &[
"navigate",
"go_back",
"scroll",
"press_key",
"wait_for",
"click_element",
"click_by_index",
"click_locator",
"type_text",
"type_by_index",
"type_locator",
"select_option",
"get_page_info",
];
const MAX_BATCH_ACTIONS: usize = 20;
/// How much of each step's answer a batch reports.
const BATCH_RESULT_CHARS: usize = 2_000;
/// How much of a captured body `get_network_request` shows when not told, and
/// at most. The most stays under what one tool answer carries.
const DEFAULT_BODY_CHARS: u64 = 20_000;
const MAX_BODY_CHARS: u64 = 90_000;
/// A tab helper's coded refusal as a tool error.
fn tab_error(message: String) -> McpError {
McpError {
code: -32000,
message,
data: None,
}
}
/// Where an action left the page, read in one round trip after it.
const PAGE_STATE_SCRIPT: &str = "(() => ({ url: location.href, title: document.title, text: (document.body ? document.body.innerText : '').slice(0, 1500) }))()";
#[derive(Debug, Default, Deserialize)]
struct PageState {
#[serde(default)]
url: String,
#[serde(default)]
title: String,
/// The start of the visible text, only to recognise a bot check.
#[serde(default)]
text: String,
}
/// Titles that a bot check or block page shows instead of the site.
const WALL_TITLES: &[(&str, &str)] = &[
("just a moment", "a Cloudflare check"),
("attention required", "a Cloudflare block"),
("ddos-guard", "a DDoS-Guard check"),
("pardon our interruption", "a bot check"),
("are you a robot", "a CAPTCHA"),
("captcha", "a CAPTCHA"),
("security check", "a security check"),
("access denied", "an access block"),
("403 forbidden", "an access block"),
];
/// Phrases specific enough that page text alone says it is a bot check.
/// A plain mention of a CAPTCHA is not one: many login forms carry it.
const WALL_PHRASES: &[(&str, &str)] = &[
("verify you are human", "a human check"),
("checking your browser before accessing", "a browser check"),
(
"checking if the site connection is secure",
"a Cloudflare check",
),
("press & hold", "a press-and-hold check"),
("please complete the security check", "a security check"),
(
"enable javascript and cookies to continue",
"a browser check",
),
];
/// The bot check standing in front of the page, if it looks like one.
fn bot_wall(title: &str, text: &str) -> Option<&'static str> {
let title = title.to_lowercase();
let text = text.to_lowercase();
WALL_TITLES
.iter()
.find(|(needle, _)| title.contains(needle))
.or_else(|| {
WALL_PHRASES
.iter()
.find(|(needle, _)| text.contains(needle))
})
.map(|(_, wall)| *wall)
}
/// The line an action ends with: the page's title and address, and a hint
/// when a bot check stands in front of the site.
fn page_report(state: &PageState) -> String {
let title = if state.title.trim().is_empty() {
"(no title)"
} else {
state.title.trim()
};
let mut line = format!("Page: {title} <{}>", state.url);
if let Some(wall) = bot_wall(&state.title, &state.text) {
line.push_str(&format!(
"\nThis looks like {wall}, not the site itself. wait_for the site's content, or ask_human to get past it."
));
}
line
}
/// The viewport and page sizes `Page.getLayoutMetrics` reports, in CSS pixels.
#[derive(Debug, Clone, Copy, PartialEq)]
struct LayoutMetrics {
x: f64,
y: f64,
width: f64,
height: f64,
page_width: f64,
page_height: f64,
/// Device pixels per CSS pixel.
dpr: f64,
}
impl LayoutMetrics {
fn from_reply(reply: &serde_json::Value) -> Self {
let number = |path: &str| reply.pointer(path).and_then(|v| v.as_f64());
let width = number("/cssVisualViewport/clientWidth")
.or_else(|| number("/cssLayoutViewport/clientWidth"))
.unwrap_or(1280.0);
let height = number("/cssVisualViewport/clientHeight")
.or_else(|| number("/cssLayoutViewport/clientHeight"))
.unwrap_or(800.0);
// The deprecated viewport is in device pixels, so the two give the ratio.
let dpr = number("/visualViewport/clientWidth")
.map(|device| device / width)
.filter(|dpr| dpr.is_finite() && *dpr >= 0.5 && *dpr <= 8.0)
.unwrap_or(1.0);
Self {
x: number("/cssVisualViewport/pageX").unwrap_or(0.0),
y: number("/cssVisualViewport/pageY").unwrap_or(0.0),
width,
height,
page_width: number("/cssContentSize/width")
.or_else(|| number("/contentSize/width"))
.unwrap_or(width),
page_height: number("/cssContentSize/height")
.or_else(|| number("/contentSize/height"))
.unwrap_or(height),
dpr,
}
}
}
/// The capture scale that keeps an image `css_width` wide at most `max_width`
/// device pixels across. Never above 1: a capture is never upscaled.
fn screenshot_scale(css_width: f64, dpr: f64, max_width: f64) -> f64 {
let output = css_width * dpr;
if !(output.is_finite() && output > 0.0) {
return 1.0;
}
(max_width / output).clamp(0.05, 1.0)
}
/// The wheel distance for `pages` viewports in `direction`. A page is 85% of
/// the viewport, so a line of context stays in view.
fn scroll_delta(direction: &str, pages: f64, width: f64, height: f64) -> Option<(f64, f64)> {
let (x, y) = (width * 0.85 * pages, height * 0.85 * pages);
match direction {
"down" => Some((0.0, y)),
"up" => Some((0.0, -y)),
"right" => Some((x, 0.0)),
"left" => Some((-x, 0.0)),
_ => None,
}
}
/// A key as `Input.dispatchKeyEvent` takes it.
#[derive(Debug, PartialEq)]
struct KeyStroke {
key: String,
code: String,
key_code: u32,
/// What the key types, when it types something.
text: Option<String>,
}
/// The named keys an agent needs most, and any single character.
fn key_stroke(name: &str) -> Option<KeyStroke> {
let named = |key: &str, code: &str, key_code: u32, text: Option<&str>| KeyStroke {
key: key.to_string(),
code: code.to_string(),
key_code,
text: text.map(str::to_string),
};
Some(match name {
"Enter" => named("Enter", "Enter", 13, Some("\r")),
"Tab" => named("Tab", "Tab", 9, None),
"Escape" => named("Escape", "Escape", 27, None),
"Backspace" => named("Backspace", "Backspace", 8, None),
"Delete" => named("Delete", "Delete", 46, None),
"Space" | " " => named(" ", "Space", 32, Some(" ")),
"ArrowUp" => named("ArrowUp", "ArrowUp", 38, None),
"ArrowDown" => named("ArrowDown", "ArrowDown", 40, None),
"ArrowLeft" => named("ArrowLeft", "ArrowLeft", 37, None),
"ArrowRight" => named("ArrowRight", "ArrowRight", 39, None),
"PageUp" => named("PageUp", "PageUp", 33, None),
"PageDown" => named("PageDown", "PageDown", 34, None),
"Home" => named("Home", "Home", 36, None),
"End" => named("End", "End", 35, None),
_ => {
let mut chars = name.chars();
let c = chars.next()?;
if chars.next().is_some() || c.is_control() {
return None;
}
let upper = c.to_ascii_uppercase();
let (code, key_code) = if c.is_ascii_alphabetic() {
(format!("Key{upper}"), upper as u32)
} else if c.is_ascii_digit() {
(format!("Digit{c}"), c as u32)
} else {
(String::new(), 0)
};
KeyStroke {
key: c.to_string(),
code,
key_code,
text: Some(c.to_string()),
}
}
})
}
/// The `modifiers` bit field `Input.dispatchKeyEvent` takes.
fn modifier_bits(modifiers: &[&str]) -> Result<u32, String> {
modifiers.iter().try_fold(0, |bits, modifier| {
Ok(
bits
| match *modifier {
"alt" => 1,
"ctrl" => 2,
"meta" => 4,
"shift" => 8,
other => return Err(format!("Unknown modifier: {other}")),
},
)
})
}
/// What `wait_for` answers: whether the wait was met, and where the page is.
fn wait_report(met: bool, elapsed: Duration, state: &PageState) -> serde_json::Value {
serde_json::json!({
"met": met,
"elapsed_ms": elapsed.as_millis() as u64,
"url": state.url,
"title": state.title,
})
}
/// How often `wait_for` looks, and the longest it may wait.
const WAIT_FOR_POLL: Duration = Duration::from_millis(250);
const WAIT_FOR_DEFAULT_MS: u64 = 10_000;
const WAIT_FOR_MAX_MS: u64 = 30_000;
/// How long a wheel scroll takes to settle before its position is read.
const SCROLL_SETTLE: Duration = Duration::from_millis(300);
/// The check `wait_for` runs: true while the text or element is on the page.
fn wait_for_script(text: Option<&str>, selector: Option<&str>) -> Option<String> {
match (text, selector) {
(Some(text), None) => Some(format!(
"(() => (document.body ? document.body.innerText : '').includes({}))()",
serde_json::to_string(text).ok()?
)),
(None, Some(selector)) => Some(format!(
"(() => {{ const el = document.querySelector({}); return !!el && el.getClientRects().length > 0; }})()",
serde_json::to_string(selector).ok()?
)),
_ => None,
}
}
fn typing_report(prefix: &str, inscription: &vellum::Inscription) -> String {
format!(
"{prefix} ({} characters, {} corrected, {:.0} ms)",
inscription.characters, inscription.corrections, inscription.duration_ms
)
}
impl McpServer {
pub(crate) fn new() -> Self {
Self {
inner: Arc::new(AsyncMutex::new(McpServerInner {
app_handle: None,
sessions: HashMap::new(),
})),
engine_ready: AtomicBool::new(false),
}
}
pub fn instance() -> &'static McpServer {
&MCP_SERVER
}
/// Hand the engine the app handle every tool needs, once, at startup.
pub async fn attach_app_handle(&self, app_handle: AppHandle) {
let mut inner = self.inner.lock().await;
if inner.app_handle.is_none() {
inner.app_handle = Some(app_handle);
}
self.engine_ready.store(true, Ordering::SeqCst);
}
/// Whether the tool engine can answer a JSON-RPC message.
pub fn is_engine_ready(&self) -> bool {
self.engine_ready.load(Ordering::SeqCst)
}
/// Let a test drive the engine without a Tauri `AppHandle`.
///
/// Exists so the bridge's transport can be exercised end to end, a real
/// socket carrying a real `tools/list`, instead of only against a mock of
/// the thing under test. `attach_app_handle` needs an `AppHandle` no unit
/// test has, and the tools this unlocks (`ping`, `tools/list`) read no app
/// state; every tool that DOES need the handle still refuses without one, so
/// this cannot make a test pass that production would fail.
#[cfg(test)]
pub(crate) fn mark_engine_ready_for_tests(&self) {
self.engine_ready.store(true, Ordering::SeqCst);
}
/// Gate an MCP tool on a capability the caller already resolved (e.g.
/// `CLOUD_AUTH.can_use_browser_automation().await`). Logs the rejected gate
/// with enough state for support to diagnose, without leaking secrets.
async fn require_capability(feature: &str, allowed: bool) -> Result<(), McpError> {
if !allowed {
let summary = match CLOUD_AUTH.get_user().await {
Some(state) => format!(
"logged_in=true plan={} status={} period={:?}",
state.user.plan, state.user.subscription_status, state.user.plan_period,
),
None => "logged_in=false".to_string(),
};
log::warn!("MCP tool refused: plan lacks feature feature=\"{feature}\" {summary}");
return Err(McpError {
code: -32000,
message: format!("{feature} is not included in this account's plan"),
data: Some(serde_json::json!({ "code": "PLAN_REQUIRED", "feature": feature })),
});
}
Ok(())
}
/// Largest JSON-RPC payload the engine will parse.
pub(crate) const MAX_MESSAGE_BYTES: usize = 1024 * 1024;
/// The longest a session teardown will spend deleting page globals.
///
/// Best effort and time-boxed on purpose. `end_session` is awaited by the
/// bridge's session teardown, and a browser that has stopped answering can hold a
/// single CDP call open for a minute, so an unbounded cleanup turns "forget
/// my session" into a hang. Losing a delete costs nothing worth waiting for:
/// a browser that cannot answer has no page left to leak, and the page-side
/// slot cap reclaims anything a missed delete leaves behind.
const CACHE_RELEASE_BUDGET: std::time::Duration = std::time::Duration::from_secs(5);
/// Forget a session, on the server AND in the pages it left snapshots in.
/// Idempotent; an unknown id is not an error, because a caller tearing down a
/// session it already lost has nothing to fix.
pub(crate) async fn end_session(&self, session_id: &str) {
let cached_pages = {
let mut inner = self.inner.lock().await;
match inner.sessions.remove(session_id) {
Some(session) => {
log::info!("MCP session ended session={}", ShortId(session_id));
crate::agent_console::session_ended(session_id);
session.cached_pages
}
None => return,
}
};
if cached_pages.is_empty() {
return;
}
// Dropping the server-side session is only half of ending it: the other
// half is an array of live element references sitting in somebody's still
// open tab, which nothing else ever removes.
if tokio::time::timeout(
Self::CACHE_RELEASE_BUDGET,
self.release_cached_pages(cached_pages),
)
.await
.is_err()
{
log::debug!(
"MCP session element caches not cleared in time session={}",
ShortId(session_id)
);
}
}
/// Delete the interactive-element slots a session wrote, page by page.
///
/// Every failure here is logged and skipped rather than surfaced: the session
/// is already gone, the caller asked to forget it, and a closed browser is
/// the commonest reason a delete cannot land, which is also the case where
/// there is nothing left to clean.
async fn release_cached_pages(&self, cached_pages: HashSet<(String, String)>) {
let registry = INTERACTIVE_SLOT_REGISTRY;
for (profile_id, slot) in cached_pages {
let target = match self.resolve_cdp_target(&profile_id).await {
Ok(target) => target,
Err(e) => {
log::debug!(
"MCP element cache clear skipped profile={profile_id} err=\"{}\"",
e.message
);
continue;
}
};
let js = format!(
r#"(() => {{
try {{ delete window[{slot}]; }} catch (e) {{ window[{slot}] = undefined; }}
const registry = window[{registry}];
if (Array.isArray(registry)) window[{registry}] = registry.filter((s) => s !== {slot});
return true;
}})()"#
);
if let Err(e) = self
.send_cdp(
&target,
"Runtime.evaluate",
serde_json::json!({
"expression": js,
"returnByValue": true,
}),
)
.await
{
log::debug!(
"MCP element cache clear failed profile={profile_id} err=\"{}\"",
e.message
);
}
}
}
/// Remember that this session left a snapshot on this profile's page, so
/// `end_session` knows which global to delete and where.
///
/// Silently does nothing when the session has already been forgotten: the
/// teardown that removed it has already run, and re-adding the entry would
/// resurrect a session-shaped record nothing will ever clean up again.
async fn remember_cached_page(&self, session_id: &str, profile_id: &str, slot: &str) {
let mut inner = self.inner.lock().await;
if let Some(session) = inner.sessions.get_mut(session_id) {
session
.cached_pages
.insert((profile_id.to_string(), slot.to_string()));
}
}
/// Answer one JSON-RPC message.
///
/// This is the whole protocol: parse, route `initialize`, absorb
/// notifications, validate the session, meter the automation tools, dispatch.
#[cfg(test)]
pub(crate) async fn handle_message(&self, session_id: Option<&str>, body: &[u8]) -> McpOutcome {
self.handle_message_with(session_id, body, None).await
}
/// `handle_message` for a caller that connected with toolsets
/// (`?toolsets=` on the endpoint URL, relayed by the bridge).
pub(crate) async fn handle_message_with(
&self,
session_id: Option<&str>,
body: &[u8],
toolsets: Option<&str>,
) -> McpOutcome {
let toolsets = crate::mcp_toolsets::parse(toolsets);
if body.len() > Self::MAX_MESSAGE_BYTES {
return McpOutcome::BadRequest;
}
let request: McpRequest = match serde_json::from_slice(body) {
Ok(request) => request,
Err(_) => return McpOutcome::BadRequest,
};
if request.method == "initialize" {
return match self.handle_initialize(request, toolsets.as_ref()).await {
Ok((new_session_id, (id, result))) => McpOutcome::Body {
body: serde_json::to_value(McpResponse {
jsonrpc: "2.0".to_string(),
id: Some(id),
result: Some(result),
error: None,
})
.unwrap_or_else(|_| serde_json::json!({})),
new_session_id: Some(new_session_id),
},
Err((id, error)) => McpOutcome::Body {
body: serde_json::to_value(McpResponse {
jsonrpc: "2.0".to_string(),
id: Some(id),
result: None,
error: Some(error),
})
.unwrap_or_else(|_| serde_json::json!({})),
new_session_id: None,
},
};
}
// A message with no id is a notification, and JSON-RPC forbids replying to
// one. `notifications/initialized` is the only one that carries meaning.
if request.id.is_none() {
if request.method == "notifications/initialized" {
if let Some(sid) = session_id {
let mut inner = self.inner.lock().await;
if let Some(session) = inner.sessions.get_mut(sid) {
session.initialized = true;
}
}
}
return McpOutcome::Accepted;
}
// Validated only when the caller supplied one: a client that never called
// `initialize` is still served, exactly as it was over HTTP. That leniency
// stops at the tools whose ANSWER depends on which caller is asking, the
// index-based ones, because there is no such thing as a correct reply to
// "click element 3" from a caller whose snapshot the server cannot name.
// They refuse in the handler (`require_indexed_session`); everything else
// is a self-contained request that a sessionless caller can safely make.
if let Some(sid) = session_id {
let mut inner = self.inner.lock().await;
// Touched HERE, on the path every id-carrying request takes, so the cap
// evicts what is genuinely idle. Doing it in the
// `notifications/initialized` branch instead, as the first attempt did -
// touches a session exactly once in its life, which is no better than
// evicting by creation time.
match inner.sessions.get_mut(sid) {
Some(session) => session.last_used = std::time::Instant::now(),
None => return McpOutcome::UnknownSession,
}
}
if Self::is_automation_tool_call(&request) && !Self::refused_by_console(&request) {
if let crate::automation_rate_limiter::RateLimitOutcome::Limited { retry_after_secs } =
crate::automation_rate_limiter::check_automation_rate_limit().await
{
if let Some(dropped) = AUTOMATION_QUOTA.allow("limited") {
log::warn!(
"MCP automation rate limited retry_after_s={retry_after_secs}{}",
crate::log_streak::suppressed(dropped)
);
}
return McpOutcome::RateLimited { retry_after_secs };
}
}
let response = self
.handle_request(
McpCaller {
session: session_id,
toolsets: toolsets.as_ref(),
},
request,
)
.await;
McpOutcome::Body {
body: serde_json::to_value(response).unwrap_or_else(|_| serde_json::json!({})),
new_session_id: None,
}
}
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;
};
Self::is_automation_tool(tool_name)
}
/// A call the person has paused or taken over does not count against the quota.
fn refused_by_console(request: &McpRequest) -> bool {
let Some(params) = request.params.as_ref() else {
return false;
};
let tool_name = params
.get("name")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let empty = serde_json::json!({});
let arguments = params.get("arguments").unwrap_or(&empty);
crate::agent_console::check_call(Self::is_passive_tool(tool_name), arguments).is_err()
}
fn is_automation_tool(tool_name: &str) -> bool {
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"
// The agent surface drives the same browser through the same paid
// gate; a native page read is automation exactly as a script one is.
| "perceive_page"
| "resolve_locator"
| "click_locator"
| "type_locator"
| "extract_structured"
| "pick_element"
| "select_option"
| "file_upload"
// A capture reads every request the browser makes, so starting one
// and reading it are browser reads like get_page_content, and are
// refused while the person has paused agents or holds the profile.
| "start_network_capture"
| "list_network_requests"
| "get_network_request"
// Starting a bot run leases a remote host for up to two hours and
// spends the account's pooled remote-hour budget, which makes it the
// most expensive tool here. Cancelling one reaches the same fleet, and
// is metered alongside the remote-session stop it mirrors.
//
// Deliberately absent: set_cookie_bot_schedule and
// delete_cookie_bot_schedule. They are configuration and lease
// nothing; metering them would throttle an agent enrolling many
// profiles, and they are not what spends the account's hours.
| "run_cookie_bot_now"
| "cancel_cookie_bot_run"
// Leasing a remote host is the single most expensive action here, and
// ending one reaches the same fleet. Both are metered exactly as their
// REST equivalents already are.
| "run_profile_remote"
| "stop_remote_session"
)
}
pub fn get_tools(&self) -> Vec<McpTool> {
vec![
McpTool {
name: "list_profiles".to_string(),
description: "List Wayfern browser profiles as short summaries: id, name, group, tags, status, route (proxy or VPN, with names), running (local, remote or stopped), last launch and note. Filter with query, group, tag and running; page with limit and cursor. Use get_profile for one profile's settings and get_profile_fingerprint for its device.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Text to find in the name, the note or a tag (any case)"
},
"group": {
"type": "string",
"description": "A group name or id; \"none\" for profiles in no group"
},
"tag": { "type": "string", "description": "Only profiles with this tag" },
"running": {
"type": "boolean",
"description": "true: only running profiles (here or on the remote fleet); false: only stopped ones"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 200,
"description": "Profiles per page, 50 when left out"
},
"cursor": {
"type": "string",
"description": "next_cursor from the previous page"
}
},
"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. A profile that is already running is taken over as it is, with no restart. Requires an active Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the profile to launch"
},
"url": {
"type": "string",
"description": "Optional URL to open in the browser"
},
"headless": {
"type": "boolean",
"description": "Run the browser in headless mode"
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "kill_profile".to_string(),
description: "Stop a running browser profile. Requires an active Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the profile to stop"
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "batch_run_profiles".to_string(),
description: "Launch multiple browser profiles at once with an optional URL. Requires an active Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_ids": {
"type": "array",
"items": { "type": "string" },
"description": "UUIDs of the profiles to launch"
},
"url": {
"type": "string",
"description": "Optional URL to open in every launched profile"
},
"headless": {
"type": "boolean",
"description": "Run the browsers in headless mode"
}
},
"required": ["profile_ids"]
}),
},
McpTool {
name: "batch_stop_profiles".to_string(),
description: "Stop multiple running browser profiles at once. Requires an active Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_ids": {
"type": "array",
"items": { "type": "string" },
"description": "UUIDs of the profiles to stop"
}
},
"required": ["profile_ids"]
}),
},
McpTool {
name: "create_profile".to_string(),
description: "Create a new browser profile".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name for the new profile"
},
"browser": {
"type": "string",
"enum": ["wayfern"],
"description": "Browser engine to use"
},
"proxy_id": {
"type": "string",
"description": "Optional proxy UUID to assign"
},
"launch_hook": {
"type": "string",
"description": "Optional HTTP(S) URL to call before launch for transient proxy overrides"
},
"group_id": {
"type": "string",
"description": "Optional group UUID to assign"
},
"tags": {
"type": "array",
"items": { "type": "string" },
"description": "Optional tags for the profile"
},
"ephemeral": {
"type": "boolean",
"description": "Keep this profile's browsing data in memory only, so nothing it browses reaches real disk (default: false)"
},
"temporary": {
"type": "boolean",
"description": "Create a profile for one run: implies ephemeral, is deleted when its browser stops, and is swept at startup if it outlived a crash (default: false)"
},
"status_id": {
"type": "string",
"description": "Optional status id from list_profile_statuses"
},
"start_urls": {
"type": "array",
"items": { "type": "string" },
"description": "Optional start pages (at most 20 http(s) URLs) a launch from the app opens instead of the last session"
},
"dns_server": {
"type": "string",
"description": "Optional. DNS server the profile resolves names with, through its own proxy or VPN: https://host/path (DNS over HTTPS), tls://host[:port] (DNS over TLS), tcp://host[:port] (plain DNS) or a bare host[:port]. A bare address of a well-known public resolver such as 1.1.1.1 uses its DNS over HTTPS endpoint. If the server does not answer, connections fail instead of falling back to the proxy's resolver. Omit it to let the proxy resolve names."
}
},
"required": ["name", "browser"]
}),
},
McpTool {
name: "detect_browser_profiles".to_string(),
description: "Detect importable Chromium-family browser profiles (Chrome, Chromium, Brave) on this machine, or scan a custom folder for profile directories".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"folder": {
"type": "string",
"description": "Optional folder to scan instead of the default browser locations. Accepts a single profile dir, a Chromium user-data dir, or a folder holding one profile dir per child."
}
}
}),
},
McpTool {
name: "import_browser_profiles".to_string(),
description: "Bulk-import browser profiles from on-disk profile folders (e.g. paths returned by detect_browser_profiles). Each imported profile becomes a Wayfern profile; items are isolated so one failure doesn't stop the rest".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"source_path": {
"type": "string",
"description": "Path to the source profile directory"
},
"new_profile_name": {
"type": "string",
"description": "Name for the imported profile"
},
"proxy_id": {
"type": "string",
"description": "Optional proxy UUID to assign to this profile"
},
"vpn_id": {
"type": "string",
"description": "Optional VPN UUID to assign to this profile"
},
"browser_type": {
"type": "string",
"description": "Source browser family (chromium, brave, edge, vivaldi, opera, arc, yandex, ...). Selects which OS keychain entry holds the key that unlocks the source's cookies and passwords, so an accurate value is what makes secrets survive the import"
},
"allow_running": {
"type": "boolean",
"description": "Import even though the source browser is running. Databases are still snapshotted consistently, but site data stored in LevelDB may be captured mid-write"
}
},
"required": ["source_path", "new_profile_name"]
},
"description": "Profiles to import"
},
"group_id": {
"type": "string",
"description": "Optional group UUID assigned to every imported profile"
},
"duplicate_strategy": {
"type": "string",
"enum": ["skip", "rename"],
"description": "How to handle an already-taken profile name (default: rename with a numeric suffix)"
}
},
"required": ["items"]
}),
},
McpTool {
name: "update_profile".to_string(),
description: "Update an existing browser profile's settings".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the profile to update"
},
"name": {
"type": "string",
"description": "New name for the profile"
},
"proxy_id": {
"type": "string",
"description": "Proxy UUID to assign (empty string to remove)"
},
"launch_hook": {
"type": "string",
"description": "Launch hook URL to assign (empty string to remove)"
},
"group_id": {
"type": "string",
"description": "Group UUID to assign (empty string to remove)"
},
"tags": {
"type": "array",
"items": { "type": "string" },
"description": "Tags for the profile (replaces existing tags)"
},
"extension_group_id": {
"type": "string",
"description": "Extension group UUID to assign (empty string to remove)"
},
"proxy_bypass_rules": {
"type": "array",
"items": { "type": "string" },
"description": "Proxy bypass rules (replaces existing rules)"
},
"clear_on_close": {
"type": "boolean",
"description": "Wipe browsing data (keeping extensions and bookmarks) when the browser exits. Not available for ephemeral or password-protected profiles."
},
"status_id": {
"type": "string",
"description": "Status id from list_profile_statuses (empty string to remove)"
},
"start_urls": {
"type": "array",
"items": { "type": "string" },
"description": "Start pages, replacing the current ones (empty array to remove). At most 20 http(s) URLs."
},
"dns_server": {
"type": "string",
"description": "DNS server the profile resolves names with, through its own proxy or VPN: https://host/path (DNS over HTTPS), tls://host[:port] (DNS over TLS), tcp://host[:port] (plain DNS) or a bare host[:port]. A bare address of a well-known public resolver such as 1.1.1.1 uses its DNS over HTTPS endpoint. If the server does not answer, connections fail instead of falling back to the proxy's resolver. Empty string to let the proxy resolve names again. Takes effect at the next launch."
}
},
"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: "rotate_proxy_ip".to_string(),
description: "Give a stored proxy a new exit IP by calling its provider's change-IP link. Every profile that uses the proxy moves to the new IP, and their open connections may drop. Fails when the proxy has no change-IP link.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"proxy_id": {
"type": "string",
"description": "The id of the stored proxy"
}
},
"required": ["proxy_id"]
}),
},
McpTool {
name: "get_profile_status".to_string(),
description: "Check whether a browser profile is running and can be driven. Returns is_running (true when the browser can be driven, wherever it is), location ('local', 'remote' or 'stopped'), is_running_locally, and remote_session_id when it is running on the remote fleet.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the profile to check"
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "bulk_update_profiles".to_string(),
description: "Change the same settings on many profiles at once. Every value is checked and every profile found before anything changes, and a running profile refuses the whole batch. Fields left out stay as they are; null clears a field that can be empty. Returns how many profiles changed.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_ids": {
"type": "array",
"items": { "type": "string" },
"description": "UUIDs of the profiles to change"
},
"patch": {
"type": "object",
"description": "The change. Keys: status_id, tags_add, tags_remove, note, group_id, extension_group_id, dns_blocklist, dns_server, proxy_bypass_rules, start_urls, launch_hook, clear_on_close, randomize_fingerprint_on_launch, webrtc_mode (auto|tcp_only|block), block_images, block_webgl, restore_session, new_device (true makes the next launch generate a new device)."
}
},
"required": ["profile_ids", "patch"]
}),
},
McpTool {
name: "list_profile_statuses".to_string(),
description: "List the profile statuses (named, colored labels such as Active or Banned) in their order. Set one on a profile with update_profile's status_id.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
McpTool {
name: "list_profile_templates".to_string(),
description: "List the profile templates: saved settings (proxy or VPN, group, tags, status, DNS, start pages, fingerprint OS and options) that new profiles are made from. A template never holds a device; every profile made from it gets its own.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
McpTool {
name: "create_profiles_from_template".to_string(),
description: "Make 1 to 100 profiles from a template with the newest downloaded Wayfern, each with its own new device. Everything is checked first, each proxy once. One profile takes the name as given; several take it followed by numbers that continue after the highest in use. Creation stops at the hourly profile allowance; the result lists what was made and why it stopped early, if it did.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"template_id": {
"type": "string",
"description": "The template's id, from list_profile_templates"
},
"count": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "How many profiles to make"
},
"name": {
"type": "string",
"description": "Optional. Defaults to the template's name prefix, else its name"
}
},
"required": ["template_id", "count"]
}),
},
// 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"]
}),
},
McpTool {
name: "distribute_proxies".to_string(),
description: "Give each profile its own proxy. Pairs are applied one \
profile at a time and every outcome is reported, so a \
profile whose browser is running is refused by name \
instead of failing the whole request."
.to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"pairs": {
"type": "array",
"description": "Profile/proxy pairs to apply, one proxy per profile",
"items": {
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the profile to move"
},
"proxy_id": {
"type": "string",
"description": "The UUID of the stored proxy to assign"
}
},
"required": ["profile_id", "proxy_id"]
}
}
},
"required": ["pairs"]
}),
},
// Full proxy management tools
McpTool {
name: "get_proxy".to_string(),
description: "Get details of a specific proxy".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"proxy_id": {
"type": "string",
"description": "The UUID of the proxy to retrieve"
}
},
"required": ["proxy_id"]
}),
},
McpTool {
name: "create_proxy".to_string(),
description: "Create a new proxy configuration.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The name for the new proxy"
},
"proxy_type": {
"type": "string",
"enum": MCP_PROXY_TYPES,
"description": "The proxy protocol"
},
"host": {
"type": "string",
"description": "The proxy host address (for regular proxies)"
},
"port": {
"type": "integer",
"description": "The proxy port number (for regular proxies)"
},
"username": {
"type": "string",
"description": "Optional username for authentication (for regular proxies)"
},
"password": {
"type": "string",
"description": "Optional password for authentication (for regular proxies)"
},
"vless_uri": {
"type": "string",
"description": "Share link for vless, vmess, trojan and hysteria2 proxies (vless://, vmess://, trojan://, hysteria2:// or hy2://). It holds the whole server configuration, and the link's own scheme decides the stored type."
}
},
"required": ["name", "proxy_type"]
}),
},
McpTool {
name: "update_proxy".to_string(),
description: "Update an existing proxy configuration".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"proxy_id": {
"type": "string",
"description": "The UUID of the proxy to update"
},
"name": {
"type": "string",
"description": "New name for the proxy"
},
"proxy_type": {
"type": "string",
"enum": MCP_PROXY_TYPES,
"description": "The proxy protocol"
},
"host": {
"type": "string",
"description": "The proxy host address (for regular proxies)"
},
"port": {
"type": "integer",
"description": "The proxy port number (for regular proxies)"
},
"username": {
"type": "string",
"description": "Optional username for authentication (for regular proxies)"
},
"password": {
"type": "string",
"description": "Optional password for authentication (for regular proxies)"
},
"vless_uri": {
"type": "string",
"description": "Share link for vless, vmess, trojan and hysteria2 proxies (vless://, vmess://, trojan://, hysteria2:// or hy2://). It holds the whole server configuration, and the link's own scheme decides the stored type."
}
},
"required": ["proxy_id"]
}),
},
McpTool {
name: "delete_proxy".to_string(),
description: "Delete a proxy configuration".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"proxy_id": {
"type": "string",
"description": "The UUID of the proxy to delete"
}
},
"required": ["proxy_id"]
}),
},
McpTool {
name: "export_proxies".to_string(),
description: "Export all proxy configurations".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"format": {
"type": "string",
"enum": ["json", "txt"],
"description": "Export format (json for structured data, txt for URL format)"
}
},
"required": ["format"]
}),
},
McpTool {
name: "import_proxies".to_string(),
description: "Import proxy configurations from JSON or TXT content".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "The proxy configuration content to import"
},
"format": {
"type": "string",
"enum": ["json", "txt"],
"description": "Import format (json or txt)"
},
"name_prefix": {
"type": "string",
"description": "Optional prefix for imported proxy names (default: 'Imported')"
}
},
"required": ["content", "format"]
}),
},
// VPN management tools
McpTool {
name: "import_vpn".to_string(),
description: "Import a WireGuard (.conf) configuration".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "Raw WireGuard config file content"
},
"filename": {
"type": "string",
"description": "Original filename (.conf)"
},
"name": {
"type": "string",
"description": "Optional display name for the VPN config"
}
},
"required": ["content", "filename"]
}),
},
McpTool {
name: "list_vpn_configs".to_string(),
description: "List all stored VPN configurations".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
McpTool {
name: "delete_vpn".to_string(),
description: "Delete a VPN configuration".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"vpn_id": {
"type": "string",
"description": "The UUID of the VPN config to delete"
}
},
"required": ["vpn_id"]
}),
},
McpTool {
name: "connect_vpn".to_string(),
description: "Connect to a VPN configuration".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"vpn_id": {
"type": "string",
"description": "The UUID of the VPN config to connect"
}
},
"required": ["vpn_id"]
}),
},
McpTool {
name: "disconnect_vpn".to_string(),
description: "Disconnect from a VPN".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"vpn_id": {
"type": "string",
"description": "The UUID of the VPN to disconnect"
}
},
"required": ["vpn_id"]
}),
},
McpTool {
name: "get_vpn_status".to_string(),
description: "Get the connection status of a VPN".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"vpn_id": {
"type": "string",
"description": "The UUID of the VPN to check"
}
},
"required": ["vpn_id"]
}),
},
// Fingerprint management tools
McpTool {
name: "get_profile_fingerprint".to_string(),
description: "Get the fingerprint configuration for a Wayfern profile. `fingerprint` is the device the profile presents, as a JSON string, or null when there is none to show."
.to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the profile"
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "update_profile_fingerprint".to_string(),
description:
"Update the fingerprint configuration for a Wayfern profile. Requires an active Pro subscription."
.to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the profile to update"
},
"fingerprint": {
"type": "string",
"description": "JSON string of the fingerprint configuration, or null to clear"
},
"os": {
"type": "string",
"enum": ["windows", "macos", "linux"],
"description": "Operating system for fingerprint generation"
},
"restore_session": {
"type": "boolean",
"description": "Reopen the windows and tabs of the last session on an interactive launch (default: true). Automation runs never restore."
},
"webrtc_mode": {
"type": "string",
"enum": ["auto", "tcp_only", "block"],
"description": "How WebRTC may reach the network: auto (default), tcp_only (publish only the proxy's exit address), or block (no ICE candidates at all)"
},
"randomize_fingerprint_on_launch": {
"type": "boolean",
"description": "Whether to generate a new fingerprint on every launch"
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "update_profile_proxy_bypass_rules".to_string(),
description:
"Update proxy bypass rules for a profile. Requests matching these rules will connect directly, bypassing the proxy."
.to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the profile to update"
},
"rules": {
"type": "array",
"items": { "type": "string" },
"description": "Array of bypass rules. Supports hostnames (e.g. 'example.com'), IP addresses, and regex patterns."
}
},
"required": ["profile_id", "rules"]
}),
},
McpTool {
name: "update_profile_dns_blocklist".to_string(),
description:
"Update the DNS blocklist level for a profile. Blocks ads, trackers, and malware domains at the proxy level."
.to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the profile to update"
},
"level": {
"type": "string",
"enum": ["none", "light", "normal", "pro", "pro_plus", "ultimate"],
"description": "DNS blocklist level. 'none' disables blocking."
}
},
"required": ["profile_id", "level"]
}),
},
McpTool {
name: "get_dns_blocklist_status".to_string(),
description: "Get the cache status of all DNS blocklist tiers including entry counts and freshness.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
McpTool {
name: "list_extensions".to_string(),
description: "List all managed browser extensions. Requires Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
McpTool {
name: "list_extension_groups".to_string(),
description: "List all extension groups. Requires Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
McpTool {
name: "add_extension".to_string(),
description: "Add a managed browser extension from a path on the machine running Donut: a .crx or .zip archive file, or an unpacked extension folder holding a top-level manifest.json. With link set to true, which only applies to a folder, the folder is loaded in place instead of being copied into Donut, so edits to it apply on the next browser start and the extension is machine-local and never synced. Requires Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path on the machine running Donut to a .crx/.zip file or to an unpacked extension folder" },
"name": { "type": "string", "description": "Display name, used only when the manifest carries no name of its own" },
"link": { "type": "boolean", "description": "Folders only: load the folder in place instead of copying it into Donut. Linked extensions never sync. Defaults to false." }
},
"required": ["path"]
}),
},
McpTool {
name: "update_extension".to_string(),
description: "Rename a managed extension and/or replace its payload from a path on the machine running Donut: a .crx or .zip archive file, or an unpacked extension folder holding a top-level manifest.json. With link set to true, which only applies to a folder, the folder is loaded in place instead of being copied into Donut, so the extension becomes machine-local and never syncs. At least one of name or path must be given. Requires Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"extension_id": { "type": "string", "description": "The extension ID to update" },
"name": { "type": "string", "description": "New display name" },
"path": { "type": "string", "description": "Path on the machine running Donut to the .crx/.zip file or unpacked extension folder to replace the payload with" },
"link": { "type": "boolean", "description": "Folders only: load the folder in place instead of copying it into Donut. Linked extensions never sync. Defaults to false." }
},
"required": ["extension_id"]
}),
},
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: "update_extension_group".to_string(),
description: "Rename an extension group and/or replace its membership with an exact list of extension IDs. Requires Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"group_id": { "type": "string", "description": "The extension group ID to update" },
"name": { "type": "string", "description": "New name for the extension group" },
"extension_ids": {
"type": "array",
"items": { "type": "string" },
"description": "The complete set of extension IDs the group should contain, replacing the current membership"
}
},
"required": ["group_id"]
}),
},
McpTool {
name: "add_extension_to_group".to_string(),
description: "Add an extension to 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" },
"extension_id": { "type": "string", "description": "The extension ID to add to the group" }
},
"required": ["group_id", "extension_id"]
}),
},
McpTool {
name: "remove_extension_from_group".to_string(),
description: "Remove an extension from 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" },
"extension_id": { "type": "string", "description": "The extension ID to remove from the group" }
},
"required": ["group_id", "extension_id"]
}),
},
McpTool {
name: "delete_extension".to_string(),
description: "Delete a managed extension. Requires Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"extension_id": { "type": "string", "description": "The extension ID to delete" }
},
"required": ["extension_id"]
}),
},
McpTool {
name: "delete_extension_group".to_string(),
description: "Delete an extension group. Requires Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"group_id": { "type": "string", "description": "The extension group ID to delete" }
},
"required": ["group_id"]
}),
},
McpTool {
name: "assign_extension_group_to_profile".to_string(),
description: "Assign an extension group to a profile, or remove the assignment. Requires Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": { "type": "string", "description": "The profile ID" },
"extension_group_id": { "type": "string", "description": "The extension group ID, or empty string to remove" }
},
"required": ["profile_id"]
}),
},
// Cookie management tools
McpTool {
name: "import_profile_cookies".to_string(),
description: "Import cookies into a Wayfern profile from a JSON array (Puppeteer / EditThisCookie format) or a Netscape cookies.txt. Format is auto-detected. The browser must not be running.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the target profile"
},
"content": {
"type": "string",
"description": "Raw cookie file content (JSON array or Netscape cookies.txt)"
}
},
"required": ["profile_id", "content"]
}),
},
// Team lock tools
McpTool {
name: "get_team_locks".to_string(),
description: "List all active team profile locks. Requires team plan.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
McpTool {
name: "get_team_lock_status".to_string(),
description: "Check if a profile is locked by a team member. Requires team plan.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the profile to check"
}
},
"required": ["profile_id"]
}),
},
// Synchronizer tools
McpTool {
name: "start_sync_session".to_string(),
description: "Start a synchronizer session. Launches a leader profile and follower profiles, then mirrors all actions from the leader to the followers in real time. Only Wayfern profiles are supported. Requires paid subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"leader_profile_id": {
"type": "string",
"description": "The UUID of the leader profile"
},
"follower_profile_ids": {
"type": "array",
"items": { "type": "string" },
"description": "UUIDs of follower profiles"
}
},
"required": ["leader_profile_id", "follower_profile_ids"]
}),
},
McpTool {
name: "stop_sync_session".to_string(),
description: "Stop an active synchronizer session. Kills all follower profiles and the leader.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"session_id": {
"type": "string",
"description": "The sync session ID"
}
},
"required": ["session_id"]
}),
},
McpTool {
name: "get_sync_sessions".to_string(),
description: "List all active synchronizer sessions.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {}
}),
},
McpTool {
name: "remove_sync_follower".to_string(),
description: "Remove a follower from an active synchronizer session.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"session_id": {
"type": "string",
"description": "The sync session ID"
},
"follower_profile_id": {
"type": "string",
"description": "The UUID of the follower to remove"
}
},
"required": ["session_id", "follower_profile_id"]
}),
},
// Browser interaction tools
McpTool {
name: "navigate".to_string(),
description: "Navigate a running browser profile to a URL. Waits for the page to fully load before returning.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"url": {
"type": "string",
"description": "The URL to navigate to"
}
},
"required": ["profile_id", "url"]
}),
},
McpTool {
name: "screenshot".to_string(),
description: "Take a screenshot of the current page in a running browser profile. Returns base64-encoded image."
.to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"format": {
"type": "string",
"enum": ["png", "jpeg", "webp"],
"description": "Image format (default: jpeg)"
},
"quality": {
"type": "integer",
"description": "Image quality 0-100 for jpeg/webp (default: 75)"
},
"full_page": {
"type": "boolean",
"description": "Capture the full scrollable page (default: false)"
},
"max_width": {
"type": "integer",
"description": "Widest the image may be, in pixels, 320 to 4096 (default: 1280). A wider page is scaled down, never up."
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "run_actions".to_string(),
description: "Run several browser actions in one call, in order, on one profile: navigate, go_back, scroll, press_key, wait_for, the click and type tools, select_option, get_page_info. Stops at the first failure and, unless stop_on_change is false, as soon as the page address changes, since later steps were planned for the old page. Returns each step's result.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": { "type": "string", "description": "The UUID of the running profile" },
"actions": {
"type": "array",
"maxItems": 20,
"items": {
"type": "object",
"properties": {
"tool": { "type": "string", "description": "The tool to run, e.g. click_by_index" },
"arguments": { "type": "object", "description": "That tool's arguments, without profile_id" }
},
"required": ["tool"]
}
},
"stop_on_change": {
"type": "boolean",
"description": "Stop when the page address changes (default: true)"
}
},
"required": ["profile_id", "actions"]
}),
},
McpTool {
name: "list_tabs".to_string(),
description: "List the open tabs of a running profile: id, title, URL, and which one the browser tools act on (selected).".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": { "type": "string", "description": "The UUID of the running profile" }
},
"required": ["profile_id"]
}),
},
McpTool {
name: "open_tab".to_string(),
description: "Open a URL in a new tab of a running profile and make it the tab the browser tools act on.".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 open" }
},
"required": ["profile_id", "url"]
}),
},
McpTool {
name: "switch_tab".to_string(),
description: "Bring a tab to the front and make it the tab the browser tools act on. Take the id from list_tabs.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": { "type": "string", "description": "The UUID of the running profile" },
"tab_id": { "type": "string", "description": "The tab id from list_tabs" }
},
"required": ["profile_id", "tab_id"]
}),
},
McpTool {
name: "close_tab".to_string(),
description: "Close a tab of a running profile. Take the id from list_tabs.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": { "type": "string", "description": "The UUID of the running profile" },
"tab_id": { "type": "string", "description": "The tab id from list_tabs" }
},
"required": ["profile_id", "tab_id"]
}),
},
McpTool {
name: "scroll".to_string(),
description: "Scroll the page with the mouse wheel at the middle of the viewport, so an inner scroll area under it scrolls too. Returns the window's new position and whether it reached the top or bottom; call get_interactive_elements again for what came into view.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"direction": {
"type": "string",
"enum": ["down", "up", "left", "right"],
"description": "Which way to scroll (default: down)"
},
"pages": {
"type": "number",
"description": "How far, in viewports, 0.1 to 10 (default: 1)"
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "press_key".to_string(),
description: "Press one key in the focused element: Enter to submit, Tab to move on, Escape to close a dialog, arrows to move in a list. With modifiers it is a shortcut (for example key 'a' with ['ctrl']). Waits for a page load the key starts, and returns where the page is.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"key": {
"type": "string",
"description": "Enter, Tab, Escape, Backspace, Delete, Space, ArrowUp, ArrowDown, ArrowLeft, ArrowRight, PageUp, PageDown, Home, End, or one character"
},
"modifiers": {
"type": "array",
"items": { "type": "string", "enum": ["alt", "ctrl", "meta", "shift"] },
"description": "Keys held during the press"
}
},
"required": ["profile_id", "key"]
}),
},
McpTool {
name: "go_back".to_string(),
description: "Go back one page in the tab's history, as the browser's Back button does, and wait for that page. Returns where the page is.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "wait_for".to_string(),
description: "Wait until a text shows on the page, or an element matching a CSS selector is visible (or, with gone=true, until it is no longer there). Cheaper than polling with screenshots. Returns met=false when the time runs out; that is an answer, not an error.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"text": {
"type": "string",
"description": "Text to wait for in the page's visible text"
},
"selector": {
"type": "string",
"description": "CSS selector of an element to wait for"
},
"gone": {
"type": "boolean",
"description": "Wait for the text or element to go away instead (default: false)"
},
"timeout_ms": {
"type": "integer",
"description": "Longest wait in milliseconds, 100 to 30000 (default: 10000)"
}
},
"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"
},
"max_chars": {
"type": "integer",
"description": "Cap on the returned characters (default: 20000). A longer page says it was cut; pass a larger max_chars, or a selector, for the rest."
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "get_page_info".to_string(),
description: "Get metadata about the current page including URL, title, and readiness state"
.to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "get_interactive_elements".to_string(),
description: "Enumerate visible interactive elements on the page (buttons, links, inputs, etc.) as a compact indexed list. The returned indices are stable for the current page and can be used with click_by_index and type_by_index instead of guessing CSS selectors. Call this before click_by_index / type_by_index, and re-call after any navigation or major DOM change. Far cheaper in tokens than get_page_content for agentic browsing. Each line starts with [index eN marks]: eN stays the same while the element lives (pass it as element_id), `new` marks an element that appeared since your last listing, `covered` one that something else sits on, `offscreen` one outside the viewport.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"max_chars": {
"type": "integer",
"description": "Cap on the serialized output length (default: 12000). The response carries a `truncated` flag if the list was cut off: scroll, or pass a larger max_chars, for elements past the cutoff."
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "click_by_index".to_string(),
description: "Click the element at the given index from the last get_interactive_elements call. Indices are valid until the next navigation. If the click triggers navigation, waits for the new page to load before returning.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"element_id": {
"type": "string",
"description": "Instead of index: the stable id (e12) from the last get_interactive_elements response"
},
"index": {
"type": "integer",
"description": "Zero-based index from the last get_interactive_elements response"
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "type_by_index".to_string(),
description: "Focus the element at the given index from the last get_interactive_elements call and type text into it. Same human-like-typing defaults as type_text; only set instant=true when you're sure the target lacks bot detection.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"element_id": {
"type": "string",
"description": "Instead of index: the stable id (e12) from the last get_interactive_elements response"
},
"index": {
"type": "integer",
"description": "Zero-based index from the last get_interactive_elements response"
},
"text": {
"type": "string",
"description": "Text to type into the element"
},
"clear_first": {
"type": "boolean",
"description": "Clear the input before typing (default: true)"
},
"instant": {
"type": "boolean",
"description": "Paste all text at once instead of human typing. WARNING: only use on targets without bot detection."
},
"wpm": {
"type": "number",
"description": "Target words per minute for human typing (default: 80)"
}
},
"required": ["profile_id", "text"]
}),
},
// Remote fleet. An agent that could drive a remote profile but not start
// one had to be handed a session by something else — the REST API or the
// GUI — which is no use to an MCP client running on its own.
McpTool {
name: "run_profile_remote".to_string(),
description: "Start this profile on a remote host (Linux for a Wayfern profile, otherwise the profile's own operating system). The profile must have Regular cloud sync enabled. Returns a session id; poll get_remote_session until state is 'live', then drive it with navigate, screenshot, click_element and the rest exactly as you would a local profile.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the profile to run remotely"
},
"url": {
"type": "string",
"description": "Optional URL to open once the browser is up"
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "stop_remote_session".to_string(),
description: "Stop a remote session and settle what it cost. A session left running bills until the fleet's two-hour cap, so stop one as soon as you are done with it".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"session_id": {
"type": "string",
"description": "Session id returned by run_profile_remote"
}
},
"required": ["session_id"]
}),
},
// Observability. `run_profile_remote` hands back a session id and the
// word "provisioning"; without these an agent can only learn that a
// session became usable by trying to drive it and failing.
McpTool {
name: "list_remote_sessions".to_string(),
description: "List the remote browser sessions this account currently owns, with their live status".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
McpTool {
name: "get_remote_session".to_string(),
description: "Read one remote session's real state: provisioning, ready, live or closed, plus whether it can be driven yet".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"session_id": {
"type": "string",
"description": "Session id returned when the remote session was started"
}
},
"required": ["session_id"]
}),
},
McpTool {
name: "get_remote_hours_quota".to_string(),
description: "Read the pooled remote-hour budget. Bot runs and interactive remote sessions spend the same pool".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
// Cookie bot. Every one of these is a proxy onto Donut cloud, which owns
// the schedule and the browsing behaviour; the tools carry only the
// user's own choices.
McpTool {
name: "list_cookie_bot_schedules".to_string(),
description: "List profiles enrolled in the nightly cookie bot".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"scope": {
"type": "string",
"enum": ["mine", "team"],
"description": "Whose enrolments to list (default: mine)"
}
},
"required": []
}),
},
McpTool {
name: "get_cookie_bot_schedule".to_string(),
description: "Get one profile's cookie-bot enrolment, or null when it is not enrolled".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the profile"
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "set_cookie_bot_schedule".to_string(),
description: "Enrol a profile in the nightly cookie bot, or replace its enrolment. The profile must have cloud sync (not end-to-end encrypted), a recorded Windows, macOS or Linux operating system, and a proxy or VPN".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the profile to enrol"
},
"profile_name": {
"type": "string",
"description": "Label shown in run history (default: the profile's own name)"
},
"platform": {
"type": "string",
"enum": ["windows", "macos", "linux"],
"description": "Must match the platform the profile runs on remotely (linux for a Wayfern profile); taken from the profile when omitted"
},
"enabled": {
"type": "boolean",
"description": "Whether the nightly run is armed"
},
"run_at_minute": {
"type": "integer",
"description": "Minutes past local midnight, 0-1439"
},
"days_mask": {
"type": "integer",
"description": "Bitmask of local weekdays, bit 0 = Monday, 1-127"
},
"timezone": {
"type": "string",
"description": "IANA zone the run time is expressed in, e.g. Europe/Berlin"
},
"preset": {
"type": "string",
"description": "Preset id from list_cookie_bot_presets"
},
"max_minutes": {
"type": "integer",
"description": "Upper bound on one run, in minutes"
},
"sites": {
"type": "array",
"items": { "type": "string" },
"description": "Absolute http(s) URLs to browse. The bot visits only these"
},
"jitter_seconds": {
"type": "integer",
"description": "Random spread around the run time, in seconds"
},
"acknowledge_conflict": {
"type": "boolean",
"description": "Write anyway when a teammate already enrols this profile"
}
},
"required": ["profile_id", "enabled", "run_at_minute", "days_mask", "timezone", "preset", "max_minutes"]
}),
},
McpTool {
name: "delete_cookie_bot_schedule".to_string(),
description: "Turn the cookie bot off for a profile. Safe to repeat; a run already in flight is not cancelled".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the profile to unenrol"
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "check_cookie_bot_conflicts".to_string(),
description: "Ask, without writing anything, which teammates already enrol this profile and whether a proposed time would overlap theirs".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the profile"
},
"run_at_minute": {
"type": "integer",
"description": "Proposed minutes past local midnight, 0-1439"
},
"timezone": {
"type": "string",
"description": "Proposed IANA zone"
},
"days_mask": {
"type": "integer",
"description": "Proposed weekday bitmask, bit 0 = Monday"
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "list_cookie_bot_runs".to_string(),
description: "List cookie-bot runs, newest first, with how many sites each visited and what it cost".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "Restrict to one profile"
},
"scope": {
"type": "string",
"enum": ["mine", "team"],
"description": "Whose runs to list (default: mine)"
},
"limit": {
"type": "integer",
"description": "Page size, 1-100 (default: 30)"
},
"before": {
"type": "string",
"description": "Keyset cursor from a previous page's next_before"
}
},
"required": []
}),
},
McpTool {
name: "run_cookie_bot_now".to_string(),
description: "Start a cookie-bot run immediately instead of waiting for the schedule. The profile must already be enrolled: the preset and site list live in its schedule. Requires an active Pro subscription and spends the pooled remote-hour budget".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the enrolled profile to warm"
},
"max_minutes": {
"type": "integer",
"description": "Cap this run only, overriding the schedule's own"
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "cancel_cookie_bot_run".to_string(),
description: "Stop a cookie-bot run that is still going. Idempotent: cancelling a finished run returns it unchanged".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"run_id": {
"type": "string",
"description": "Run id from list_cookie_bot_runs"
}
},
"required": ["run_id"]
}),
},
McpTool {
name: "list_cookie_bot_presets".to_string(),
description: "List the cookie-bot intensities that can be chosen, with roughly how long each takes".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
McpTool {
name: "get_cookie_bot_usage".to_string(),
description: "Per-member and per-profile cookie-bot spend for a calendar month. Reporting only".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"period": {
"type": "string",
"description": "Calendar month as YYYY-MM (default: the current UTC month)"
}
},
"required": []
}),
},
McpTool {
name: "perceive_page".to_string(),
description: "Read the page the way an agent needs it: every visible element with its role, accessible name, text, value, state and page-coordinate bounds, plus the readable text, in one call and with no script injected on Wayfern 152. A capture that uses its whole budget is answered from the DOM instead (engine: \"fallback\"). Far more complete than get_interactive_elements. On a profile older than Wayfern 152 the same shape is synthesised from the DOM (engine: \"fallback\"). A truncated result carries a cursor; pass it back to continue.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"max_bytes": {
"type": "integer",
"description": "Total byte cap for nodes and text (default: 1048576, ceiling: 4194304)"
},
"budget_ms": {
"type": "integer",
"description": "Capture budget in milliseconds (default: 5000, clamped to 100-60000). A capture that uses all of it is answered from the DOM instead; it is never an error"
},
"max_nodes": {
"type": "integer",
"description": "Per-frame node ceiling (default: 100000; 0 for no limit)"
},
"include_text": {
"type": "boolean",
"description": "Include the readable text (default: true)"
},
"viewport_only": {
"type": "boolean",
"description": "Drop nodes outside the viewport (default: false)"
},
"text_order": {
"type": "string",
"enum": ["reading", "visual"],
"description": "Emit text in accessibility reading order (default) or re-sorted by geometry (Wayfern 152; the fallback engine always answers in reading order)"
},
"cursor": {
"type": "string",
"description": "Continue a previous capture from the cursor it returned (Wayfern 152 only)"
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "resolve_locator".to_string(),
description: "Resolve a locator (role, accessible name, text, attributes) to EXACTLY ONE element and describe it. Ambiguity is an error whose data.candidates lists what matched, and a locator that matches nothing is an error too, so a click never lands on the wrong element. Use the result's signature or attributes to refine.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"locator": locator_schema("How to name the element. Every part given must match"),
"candidate_limit": {
"type": "integer",
"description": "How many candidates an ambiguity error lists (default: 10, ceiling: 100)"
}
},
"required": ["profile_id", "locator"]
}),
},
McpTool {
name: "click_locator".to_string(),
description: "Resolve a locator to exactly one element and click it with a humanized pointer: on Wayfern 152 a real pointer glides to the element along a human path and presses with the profile's own timing (the click is never a script); on older builds a trusted mouse event is dispatched at the element's centre. Waits for a page load when the click causes one.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"locator": locator_schema("How to name the element to click"),
"button": {
"type": "string",
"enum": ["left", "middle", "right", "back", "forward"],
"description": "Mouse button (default: left)"
},
"click_count": {
"type": "integer",
"description": "1 for a click (default), 2 for a double click, 3 for a triple"
}
},
"required": ["profile_id", "locator"]
}),
},
McpTool {
name: "type_locator".to_string(),
description: "Resolve a locator to exactly one field, focus it with a real click and type text into it one key at a time. On Wayfern 152 the keys are paced by the profile's own typing rhythm, with a few seed-determined typos corrected along the way when typos is on; on older builds the same human-typing model as type_text is used. The field is emptied first unless clear_first is false.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"locator": locator_schema("How to name the field to type into"),
"text": {
"type": "string",
"description": "Text to type"
},
"clear_first": {
"type": "boolean",
"description": "Empty the field before typing (default: true)"
},
"typos": {
"type": "boolean",
"description": "Mistype and correct a few characters, as a hand does (default: true)"
},
"wpm": {
"type": "number",
"description": "Target words per minute for the fallback engine (default: 80). Wayfern 152 types at the profile's own rhythm and ignores this"
}
},
"required": ["profile_id", "locator", "text"]
}),
},
McpTool {
name: "select_option".to_string(),
description: "Choose options of a native <select>, named by selector, locator, or index from get_interactive_elements; a label or a container that holds one <select> also works. Each option is matched by its value, then by its visible label. The select is focused and the input and change events a pick fires are dispatched in the page. For a custom dropdown that is not a <select>, click it open and click the option instead. Waits for a page load when the choice causes one.".to_string(),
input_schema: form_tool_schema(
"select element",
serde_json::json!({
"option": {
"type": "string",
"description": "The value or visible label of the option to choose"
},
"options": {
"type": "array",
"items": { "type": "string" },
"description": "Instead of option: every option to choose, for a <select multiple>"
}
}),
&[],
),
},
McpTool {
name: "file_upload".to_string(),
description: "Put files into a file input (<input type=file>), named by selector, locator, or index; a label or a container that holds one file input also works, and the input may be hidden. Send each file's content: text for a text file, content_base64 for any file, at most 10 files and 700 KB in all per call. Donut writes them to a private folder on this computer and sets them on the input the way the file chooser does, so the page sees a trusted change; they are deleted when the browser stops. Setting files replaces what the input held. Only for profiles running on this computer.".to_string(),
input_schema: form_tool_schema(
"file input",
serde_json::json!({
"files": {
"type": "array",
"minItems": 1,
"maxItems": 10,
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The file name the page sees, with its extension (report.pdf)"
},
"text": {
"type": "string",
"description": "The content of a text file"
},
"content_base64": {
"type": "string",
"description": "Instead of text: the content as base64 (a data: URL works too)"
}
},
"required": ["name"]
}
}
}),
&["files"],
),
},
McpTool {
name: "start_network_capture".to_string(),
description: "Start recording the requests that every tab of a running profile makes, for list_network_requests and get_network_request. Nothing the page can see changes. The record is in memory only: at most 2,000 requests and text response bodies up to 2 MB each and 32 MB in all, the oldest dropped first. Cookie, Set-Cookie, Authorization and Proxy-Authorization values read [redacted]. Requests made while the person has paused agents or taken over the profile are not recorded. The capture and its record end when the browser stops, or with stop_network_capture. Only for profiles running on this computer; a capture already running is kept.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "stop_network_capture".to_string(),
description: "Stop a profile's network capture and delete its record.".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: "list_network_requests".to_string(),
description: "List the requests a profile's network capture recorded, oldest first: id, tab, method, URL, type (Document, XHR, Fetch, Script, Image...), state (pending, done, failed, redirected, incomplete), status, MIME type, bytes, duration and what became of the body (kept, or why not). Filter by URL text, method, type, status, failure or body text. Page with after and limit; last_id is the newest id recorded, so after set to it later shows only new requests.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the profile"
},
"after": {
"type": "integer",
"minimum": 0,
"description": "Only requests with a larger id: next_after or last_id from an earlier call"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 200,
"description": "Requests per page, 50 when left out"
},
"url_contains": { "type": "string", "description": "Text the URL contains (any case)" },
"method": { "type": "string", "description": "GET, POST, ..." },
"type": { "type": "string", "description": "Document, XHR, Fetch, Script, Image, ..." },
"status": { "type": "integer", "description": "Only this HTTP status" },
"failed": { "type": "boolean", "description": "true: only requests that failed; false: none of them" },
"body_contains": { "type": "string", "description": "Text a kept response body contains (any case)" }
},
"required": ["profile_id"]
}),
},
McpTool {
name: "get_network_request".to_string(),
description: "One request a network capture recorded, in full: request and response headers, the request body (up to 64 KB), status, timing, and the response body when it is text. Read a long body in parts with body_offset and max_body_chars.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the profile"
},
"request_id": {
"type": "integer",
"description": "The id from list_network_requests"
},
"body": {
"type": "boolean",
"description": "Include the response body (default: true)"
},
"body_offset": {
"type": "integer",
"minimum": 0,
"description": "The character to start the body at: next_offset from the previous call"
},
"max_body_chars": {
"type": "integer",
"minimum": 1,
"maximum": 90000,
"description": "The most body characters to show, 20000 when left out"
}
},
"required": ["profile_id", "request_id"]
}),
},
McpTool {
name: "extract_structured".to_string(),
description: "Read rows off the live page natively, with no script injected: a container locator matches every row, each field locator is evaluated inside a row, and an optional next-page locator is clicked to advance. A missing container is a result (stopReason \"no-container\"), not an error. Every bound is reported through stopReason and truncated. Requires Wayfern 152.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"container": locator_schema("Matches every row container; several matches are the expected case"),
"field_map": {
"type": "array",
"description": "The columns to read from each row",
"items": {
"type": "object",
"properties": {
"key": { "type": "string", "description": "The key this column appears under in each row's values" },
"locator": locator_schema("Evaluated inside each container; the first match wins. A field that matches nothing is an absent key"),
"source": { "type": "string", "enum": ["text", "attribute", "link"], "description": "Visible text, one named attribute, or the resolved href/src" },
"attribute": { "type": "string", "description": "The attribute to read; required when source is attribute" }
},
"required": ["key", "locator", "source"]
}
},
"next_page": locator_schema("The control clicked to advance a page; absent means one page"),
"max_pages": { "type": "integer", "description": "Default 1, ceiling 200" },
"max_rows": { "type": "integer", "description": "Default 1000, ceiling 100000" },
"max_bytes": { "type": "integer", "description": "Default 262144, ceiling 8388608" },
"max_nodes": { "type": "integer", "description": "Node cap for each snapshot (default: 20000, ceiling: 200000)" },
"time_budget_ms": { "type": "integer", "description": "Default 8000, ceiling 120000" }
},
"required": ["profile_id", "container", "field_map"]
}),
},
McpTool {
name: "pick_element".to_string(),
description: "Arm the browser's element picker and wait for the user to click an element in the page. Returns the smallest locator that resolves to what they clicked, plus its description, so a person can point at something an agent then acts on with click_locator or type_locator. Errors when the user presses Escape, navigates away, or nothing is picked within timeout_ms. Requires Wayfern 152.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"timeout_ms": {
"type": "integer",
"description": "How long to wait for the click (default: 60000, ceiling: 300000)"
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "ask_human".to_string(),
description: "Ask the person at this computer a question and wait for the answer. Use it when you are unsure what they want, need a value only they know (a code, an account choice), or before a step that cannot be undone. Offer choices when you can. Returns {status: \"answered\", answer}, or {status: \"pending\", request_id} when they have not answered within wait_seconds; then call wait_for_human.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"question": { "type": "string", "description": "One short, clear question" },
"choices": {
"type": "array",
"items": { "type": "string" },
"description": "Optional answers to pick from (at most 8)"
},
"profile_id": { "type": "string", "description": "The profile the question is about, if any" },
"wait_seconds": { "type": "integer", "description": "How long to wait for the answer (default 45, max 55)" }
},
"required": ["question"]
}),
},
McpTool {
name: "request_human_help".to_string(),
description: "Hand a profile to the person at this computer when its page needs a human: a sign-in, a verification challenge, a payment, or anything you cannot do. The profile is held for them, and your calls on it are refused with PROFILE_HELD_BY_HUMAN until they hand it back. Returns {status: \"done\", note} when they hand it back, or {status: \"pending\", request_id}; then call wait_for_human, or work on other profiles meanwhile.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": { "type": "string", "description": "The UUID of the profile that needs the person" },
"reason": { "type": "string", "description": "What the person should do, in one sentence" },
"wait_seconds": { "type": "integer", "description": "How long to wait (default 45, max 55)" }
},
"required": ["profile_id", "reason"]
}),
},
McpTool {
name: "wait_for_human".to_string(),
description: "Keep waiting for the outcome of an ask_human or request_human_help request. Returns status answered, done, dismissed, or pending (call again).".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"request_id": { "type": "integer", "description": "The request_id from ask_human or request_human_help" },
"wait_seconds": { "type": "integer", "description": "How long to wait (default 45, max 55)" }
},
"required": ["request_id"]
}),
},
McpTool {
name: "get_human_updates".to_string(),
description: "Read what the person at this computer wants you to know: their notes to you, the profiles they have taken over, whether agents are paused, and outcomes of your earlier requests. Call it when you start, between steps of a long job, and whenever a tool is refused with AGENT_PAUSED or PROFILE_HELD_BY_HUMAN.".to_string(),
input_schema: serde_json::json!({ "type": "object", "properties": {} }),
},
McpTool {
name: "report_progress".to_string(),
description: "Tell the person what you are doing, in one short sentence, with done and total counts for a job over many items. It is shown next to your name in Donut. Call it when you start a job and every few items.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"message": { "type": "string", "description": "What you are doing now (at most 280 characters)" },
"done": { "type": "integer", "description": "Items finished so far" },
"total": { "type": "integer", "description": "Items in the whole job" },
"profile_id": { "type": "string", "description": "The profile you are working on, if one" }
},
"required": ["message"]
}),
},
McpTool {
name: "send_feedback".to_string(),
description: "Send feedback about Donut Browser to the Donut team: a tool that failed or behaved unexpectedly, a capability you needed and did not find, a confusing result, or something that worked well. Say what you tried, what happened, and what you expected. Set include_logs to attach the recent app logs, which help with bugs and never contain profile names, visited URLs or credentials. The person at this computer sees what you sent. Returns {sent: true, feedback_id}.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"message": { "type": "string", "description": "The feedback (at most 5000 characters)" },
"kind": {
"type": "string",
"enum": ["bug", "idea", "praise", "other"],
"description": "bug for something broken, idea for something missing, praise for something that worked well (default: other)"
},
"include_logs": { "type": "boolean", "description": "Attach the recent app logs (default: false)" }
},
"required": ["message"]
}),
},
]
}
async fn handle_initialize(
&self,
request: McpRequest,
toolsets: Option<&crate::mcp_toolsets::Selection>,
) -> 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_engine_ready() {
return Err((
id,
McpError {
code: -32001,
message: "MCP server is not running".to_string(),
data: None,
},
));
}
let negotiated = negotiate_protocol_version(
request
.params
.as_ref()
.and_then(|params| params.get("protocolVersion"))
.and_then(serde_json::Value::as_str),
);
let client_info = request
.params
.as_ref()
.and_then(|params| params.get("clientInfo"));
let client_field = |key: &str| {
client_info
.and_then(|info| info.get(key))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
};
let (client_name, client_version) = (client_field("name"), client_field("version"));
// Create session
let session_id = Uuid::new_v4().to_string();
let mut evicted = Vec::new();
{
let mut inner = self.inner.lock().await;
inner.sessions.insert(
session_id.clone(),
McpSession {
initialized: false,
last_used: std::time::Instant::now(),
cached_pages: HashSet::new(),
},
);
// Evict the oldest rather than refusing the newest: a caller that just
// asked for a session is the one actually present, and refusing it would
// break a live customer to protect memory that is not under pressure.
//
// An evicted session's page snapshots are NOT deleted here. Doing it
// would hold this lock, and the caller's `initialize`, on CDP round trips
// to browsers that may be gone; the page-side slot cap
// (MAX_CACHE_SLOTS_PER_PAGE) is what bounds them in exactly this case.
while inner.sessions.len() > MAX_SESSIONS {
let Some(oldest) = inner
.sessions
.iter()
.min_by_key(|(_, session)| session.last_used)
.map(|(id, _)| id.clone())
else {
break;
};
log::warn!(
"MCP session cap reached, oldest evicted session={}",
ShortId(&oldest)
);
inner.sessions.remove(&oldest);
evicted.push(oldest);
}
}
crate::agent_console::session_started(&session_id, client_name, client_version);
for oldest in evicted {
crate::agent_console::session_ended(&oldest);
}
let mut instructions = INITIALIZE_INSTRUCTIONS.to_string();
if !CLOUD_AUTH.can_use_browser_automation().await {
instructions.push_str(PLAN_WITHOUT_AUTOMATION);
}
if let Some(selection) = toolsets {
let missing = selection.missing();
if !missing.is_empty() {
instructions.push_str(&format!(
" This connection lists the toolsets {}. Not listed: {}. To use them, reconnect with ?toolsets= naming them, or with no toolsets for every tool.",
selection.names().join(", "),
missing.join(", ")
));
}
}
let result = serde_json::json!({
"protocolVersion": negotiated,
"capabilities": {
"tools": {
"listChanged": false
}
},
"serverInfo": {
"name": SERVER_NAME,
"version": SERVER_VERSION,
},
"instructions": instructions
});
log::info!("MCP session started session={}", ShortId(&session_id));
Ok((session_id, (id, result)))
}
pub async fn handle_request(&self, caller: McpCaller<'_>, request: McpRequest) -> McpResponse {
let id = request.id.clone().unwrap_or(serde_json::Value::Null);
if !self.is_engine_ready() {
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(),
data: None,
}),
};
}
let result = match request.method.as_str() {
"ping" => Ok(serde_json::json!({})),
"tools/list" => self.handle_tools_list(caller.toolsets).await,
"tools/call" => self.handle_tool_call(caller, request.params).await,
_ => Err(McpError {
code: -32601,
message: format!("Method not found: {}", request.method),
data: None,
}),
};
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,
toolsets: Option<&crate::mcp_toolsets::Selection>,
) -> Result<serde_json::Value, McpError> {
let tools: Vec<serde_json::Value> = self
.get_tools()
.into_iter()
.filter(|tool| toolsets.is_none_or(|selection| selection.includes_tool(&tool.name)))
.map(|tool| {
let output_schema = crate::mcp_views::output_schema_for(&tool.name);
let mut value = serde_json::to_value(tool).unwrap_or_default();
if let Some(schema) = output_schema {
value["outputSchema"] = schema;
}
value
})
.collect();
Ok(serde_json::json!({ "tools": tools }))
}
async fn handle_tool_call(
&self,
caller: McpCaller<'_>,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value, McpError> {
let params = params.ok_or_else(|| McpError {
code: -32602,
message: "Missing parameters".to_string(),
data: None,
})?;
let tool_name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing tool name".to_string(),
data: None,
})?;
let arguments = params
.get("arguments")
.cloned()
.unwrap_or(serde_json::json!({}));
// Log only the tool name and the profile_id arg: arbitrary URLs / JS /
// selectors can be sensitive.
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.unwrap_or("<none>");
let started = std::time::Instant::now();
// Refused here, at the one place every tool call passes, rather than in
// each handler: a new path-taking tool added later inherits the rule
// instead of having to remember it.
if LOCAL_PATH_TOOLS.contains(&tool_name) || SECRET_EXPORT_TOOLS.contains(&tool_name) {
log::warn!("MCP tool refused: local paths or secrets tool={tool_name}");
return Err(McpError {
code: -32000,
message: crate::backend_error("TOOL_IS_LOCAL_ONLY"),
data: None,
});
}
if let Some(refused) = crate::team_roles::tool_refusal(tool_name) {
return Err(McpError {
code: -32000,
message: refused,
data: None,
});
}
let human_tool = crate::agent_console::HUMAN_TOOLS.contains(&tool_name);
if let Err(refusal) =
crate::agent_console::check_call(Self::is_passive_tool(tool_name), &arguments)
{
let error = Self::console_refusal(refusal);
crate::agent_console::record_call(crate::agent_console::CallRecord {
session_id: caller.session,
tool: tool_name,
arguments: &arguments,
error_code: Some(Self::error_code_of(&error)),
duration_ms: 0,
})
.await;
return Err(error);
}
let mut result = self
.dispatch_tool_call(caller, tool_name, &arguments)
.await
.map_err(|error| explain_engine_refusal(error, CLOUD_AUTH.wayfern_device_refusal()));
let elapsed_ms = started.elapsed().as_millis();
if human_tool {
crate::agent_console::touch_session(caller.session);
} else {
crate::agent_console::record_call(crate::agent_console::CallRecord {
session_id: caller.session,
tool: tool_name,
arguments: &arguments,
error_code: result.as_ref().err().map(Self::error_code_of),
duration_ms: elapsed_ms as u64,
})
.await;
}
if tool_name != "get_human_updates" {
if let Ok(value) = result.as_mut() {
Self::attach_notes(value, caller.session);
}
}
match &result {
Ok(_) => {
log::debug!(
"MCP tool call ok tool={tool_name} profile={profile_id} elapsed_ms={elapsed_ms}"
);
}
Err(e) => {
log::warn!(
"MCP tool call failed tool={tool_name} profile={profile_id} code={} elapsed_ms={elapsed_ms} err={:?}",
e.code,
e.message
);
}
}
result
}
async fn dispatch_tool_call(
&self,
caller: McpCaller<'_>,
tool_name: &str,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
match tool_name {
"ask_human" => self.handle_ask_human(caller, arguments).await,
"request_human_help" => self.handle_request_human_help(caller, arguments).await,
"wait_for_human" => self.handle_wait_for_human(arguments).await,
"get_human_updates" => {
Self::json_content(&crate::agent_console::human_updates(caller.session))
}
"report_progress" => Self::handle_report_progress(caller, arguments),
"send_feedback" => self.handle_send_feedback(caller, arguments).await,
"list_profiles" => self.handle_list_profiles(arguments).await,
"get_profile" => self.handle_get_profile(arguments).await,
"run_profile" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_run_profile(arguments).await
}
"kill_profile" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_kill_profile(arguments).await
}
"batch_run_profiles" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_batch_run_profiles(arguments).await
}
"batch_stop_profiles" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_batch_stop_profiles(arguments).await
}
"create_profile" => self.handle_create_profile(arguments).await,
// Profile import (free, like create_profile — importing is not automation)
"detect_browser_profiles" => self.handle_detect_browser_profiles(arguments).await,
"import_browser_profiles" => self.handle_import_browser_profiles(arguments).await,
"update_profile" => self.handle_update_profile(arguments).await,
"delete_profile" => self.handle_delete_profile(arguments).await,
"list_tags" => self.handle_list_tags().await,
"list_proxies" => self.handle_list_proxies().await,
"rotate_proxy_ip" => {
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(),
data: None,
})?;
let result = crate::proxy_rotation::rotate(proxy_id)
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Proxy IP change failed: {e}"),
data: None,
})?;
Self::json_content(&result)
}
"get_profile_status" => self.handle_get_profile_status(arguments).await,
// Group management
"list_groups" => self.handle_list_groups().await,
"bulk_update_profiles" => {
let profile_ids: Vec<String> = arguments
.get("profile_ids")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_ids".to_string(),
data: None,
})?;
let patch: crate::profile::patch::ProfilePatch = arguments
.get("patch")
.cloned()
.map(serde_json::from_value)
.transpose()
.map_err(|e| McpError {
code: -32602,
message: format!("Invalid patch: {e}"),
data: None,
})?
.unwrap_or_default();
let changed = ProfileManager::instance()
.apply_patch(&profile_ids, patch)
.map_err(|e| McpError {
code: -32000,
message: format!("Bulk edit failed: {e}"),
data: None,
})?;
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!("{changed} of {} profiles changed", profile_ids.len())
}]
}))
}
"list_profile_statuses" => Self::json_content(&crate::profile_statuses::list()),
"list_profile_templates" => Self::json_content(&crate::profile_templates::list()),
"create_profiles_from_template" => self.handle_create_profiles_from_template(arguments).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,
"distribute_proxies" => self.handle_distribute_proxies(arguments).await,
// Full proxy management
"get_proxy" => self.handle_get_proxy(arguments).await,
"create_proxy" => self.handle_create_proxy(arguments).await,
"update_proxy" => self.handle_update_proxy(arguments).await,
"delete_proxy" => self.handle_delete_proxy(arguments).await,
// Proxy import/export
"export_proxies" => self.handle_export_proxies(arguments).await,
"import_proxies" => self.handle_import_proxies(arguments).await,
// VPN management
"import_vpn" => self.handle_import_vpn(arguments).await,
"list_vpn_configs" => self.handle_list_vpn_configs().await,
"delete_vpn" => self.handle_delete_vpn(arguments).await,
"connect_vpn" => self.handle_connect_vpn(arguments).await,
"disconnect_vpn" => self.handle_disconnect_vpn(arguments).await,
"get_vpn_status" => self.handle_get_vpn_status(arguments).await,
// Fingerprint management — viewing is free everywhere (matches the REST
// API and the get_profile tool, which already expose the config); only
// editing requires a paid plan.
"get_profile_fingerprint" => self.handle_get_profile_fingerprint(arguments).await,
"update_profile_fingerprint" => {
Self::require_capability(
"Fingerprint editing",
CLOUD_AUTH.can_use_cross_os_fingerprints().await,
)
.await?;
self.handle_update_profile_fingerprint(arguments).await
}
"update_profile_proxy_bypass_rules" => {
self
.handle_update_profile_proxy_bypass_rules(arguments)
.await
}
// DNS blocklist management
"update_profile_dns_blocklist" => self.handle_update_profile_dns_blocklist(arguments).await,
"get_dns_blocklist_status" => self.handle_get_dns_blocklist_status().await,
// Extension management
"list_extensions" => self.handle_list_extensions().await,
"list_extension_groups" => self.handle_list_extension_groups().await,
"add_extension" => self.handle_add_extension(arguments).await,
"update_extension" => self.handle_update_extension(arguments).await,
"create_extension_group" => self.handle_create_extension_group(arguments).await,
"update_extension_group" => self.handle_update_extension_group(arguments).await,
"add_extension_to_group" => self.handle_add_extension_to_group(arguments).await,
"remove_extension_from_group" => self.handle_remove_extension_from_group(arguments).await,
"delete_extension" => self.handle_delete_extension_mcp(arguments).await,
"delete_extension_group" => self.handle_delete_extension_group_mcp(arguments).await,
"assign_extension_group_to_profile" => {
self
.handle_assign_extension_group_to_profile(arguments)
.await
}
// Cookie management
"import_profile_cookies" => self.handle_import_profile_cookies(arguments).await,
// Team lock tools
"get_team_locks" => self.handle_get_team_locks().await,
"get_team_lock_status" => self.handle_get_team_lock_status(arguments).await,
// Synchronizer tools
"start_sync_session" => {
Self::require_capability(
"Synchronizer",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_start_sync_session(arguments).await
}
"stop_sync_session" => self.handle_stop_sync_session(arguments).await,
"get_sync_sessions" => self.handle_get_sync_sessions().await,
"remove_sync_follower" => self.handle_remove_sync_follower(arguments).await,
// Browser interaction tools (require paid subscription)
"navigate" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_navigate(arguments).await
}
"screenshot" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_screenshot(arguments).await
}
"run_actions" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_run_actions(caller, arguments).await
}
"list_tabs" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_list_tabs(arguments).await
}
"open_tab" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_open_tab(arguments).await
}
"switch_tab" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_switch_tab(arguments).await
}
"close_tab" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_close_tab(arguments).await
}
"scroll" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_scroll(arguments).await
}
"press_key" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_press_key(arguments).await
}
"go_back" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_go_back(arguments).await
}
"wait_for" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_wait_for(arguments).await
}
"evaluate_javascript" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_evaluate_javascript(arguments).await
}
"click_element" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_click_element(arguments).await
}
"type_text" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_type_text(arguments).await
}
"get_page_content" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_get_page_content(arguments).await
}
"get_page_info" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_get_page_info(arguments).await
}
"get_interactive_elements" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self
.handle_get_interactive_elements(caller, arguments)
.await
}
"click_by_index" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_click_by_index(caller, arguments).await
}
"type_by_index" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_type_by_index(caller, arguments).await
}
// The agent surface: perception, locators, extraction, the picker and
// humanized input. Gated exactly like click_element, because every one
// of them drives, or reads, the same browser.
"perceive_page" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_perceive_page(arguments).await
}
"resolve_locator" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_resolve_locator(arguments).await
}
"click_locator" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_click_locator(arguments).await
}
"type_locator" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_type_locator(arguments).await
}
"select_option" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_select_option(caller, arguments).await
}
"file_upload" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_file_upload(caller, arguments).await
}
"start_network_capture" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_start_network_capture(arguments).await
}
// Stopping only deletes; it works without a plan, like stop_sync_session.
"stop_network_capture" => Self::handle_stop_network_capture(arguments),
"list_network_requests" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
Self::handle_list_network_requests(arguments)
}
"get_network_request" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
Self::handle_get_network_request(arguments)
}
"extract_structured" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_extract_structured(arguments).await
}
"pick_element" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_pick_element(arguments).await
}
// Leasing a host is the most expensive thing this server can do, so it
// is gated exactly like the local launch it replaces.
"run_profile_remote" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
self.handle_run_profile_remote(arguments).await
}
// No capability gate on the stop. A lapsed plan must never be the reason
// an agent cannot end something that is spending hours.
"stop_remote_session" => Self::handle_stop_remote_session(arguments).await,
// Remote fleet observability. Reads only, and free: being unable to see
// that a session you are already paying for has become usable is not a
// feature worth withholding.
"list_remote_sessions" => Self::handle_list_remote_sessions().await,
"get_remote_session" => Self::handle_get_remote_session(arguments).await,
"get_remote_hours_quota" => Self::handle_get_remote_hours_quota().await,
// Cookie bot. Reading and configuring are free; only starting a run,
// which leases a host and spends the pooled hours, needs the plan.
"list_cookie_bot_schedules" => Self::handle_list_cookie_bot_schedules(arguments).await,
"get_cookie_bot_schedule" => Self::handle_get_cookie_bot_schedule(arguments).await,
"set_cookie_bot_schedule" => Self::handle_set_cookie_bot_schedule(arguments).await,
"delete_cookie_bot_schedule" => Self::handle_delete_cookie_bot_schedule(arguments).await,
"check_cookie_bot_conflicts" => Self::handle_check_cookie_bot_conflicts(arguments).await,
"list_cookie_bot_runs" => Self::handle_list_cookie_bot_runs(arguments).await,
"run_cookie_bot_now" => {
// The Cookie Bot, NOT browser automation. Solo pays for the bot and has
// no automation; gating this on automation refused a Solo customer the
// feature their plan is sold on while their scheduled runs kept firing.
Self::require_capability("Cookie Bot", CLOUD_AUTH.can_use_cookie_bot().await).await?;
Self::handle_run_cookie_bot_now(arguments).await
}
// No capability gate on the cancel. A lapsed plan must never be the
// reason an agent cannot stop something that is spending hours.
"cancel_cookie_bot_run" => Self::handle_cancel_cookie_bot_run(arguments).await,
"list_cookie_bot_presets" => Self::handle_list_cookie_bot_presets().await,
"get_cookie_bot_usage" => Self::handle_get_cookie_bot_usage(arguments).await,
_ => Err(McpError {
code: -32602,
message: format!("Unknown tool: {tool_name}"),
data: None,
}),
}
}
async fn handle_list_profiles(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let query =
crate::mcp_views::ProfileQuery::from_arguments(arguments).map_err(|message| McpError {
code: -32602,
message,
data: None,
})?;
let profiles = ProfileManager::instance()
.list_profiles()
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to list profiles: {e}"),
data: None,
})?;
Self::json_content(&crate::mcp_views::list_profiles(
&profiles,
&Self::profile_names(),
&query,
))
}
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(),
data: None,
})?;
let profiles = ProfileManager::instance()
.list_profiles()
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to list profiles: {e}"),
data: None,
})?;
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}"),
data: None,
})?;
// Check if it's a Wayfern profile
if profile.browser != "wayfern" {
return Err(McpError {
code: -32000,
message: "MCP only supports Wayfern profiles".to_string(),
data: None,
});
}
Self::json_content(&crate::mcp_views::profile_detail(
profile,
&Self::profile_names(),
))
}
async fn handle_run_profile(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
// Launching profiles programmatically requires the automation capability.
Self::require_capability(
"Launching a profile",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
data: None,
})?;
let url = arguments.get("url").and_then(|v| v.as_str());
if let Some(url) = url {
validate_navigable_url(url)?;
}
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}"),
data: None,
})?;
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}"),
data: None,
})?;
// Check if it's a Wayfern profile
if profile.browser != "wayfern" {
return Err(McpError {
code: -32000,
message: "MCP only supports Wayfern profiles".to_string(),
data: None,
});
}
// A browser the person already opened is taken over as it is, never
// restarted: the tools reach it on its DevTools port.
if let Some(port) = crate::browser_tabs::local_port(profile).await {
if let Some(url) = url {
crate::browser_tabs::open(profile, url)
.await
.map_err(tab_error)?;
}
log::info!(
"Running browser taken over profile={} cdp_port={port}",
profile.id
);
return Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!(
"Browser profile '{}' was already running; the tools now drive it. Use list_tabs to see its tabs.",
profile.name
)
}]
}));
}
// Team lock check
crate::team_lock::acquire_team_lock_if_needed(profile)
.await
.map_err(|e| McpError {
code: -32000,
message: e,
data: None,
})?;
// Get app handle to launch
// The guard is dropped before the await below. Binding `app_handle` as a
// REFERENCE out of `inner` keeps the engine's single global mutex locked
// for the whole operation — and `handle_message` needs that same mutex to
// validate the session on every request, and to serve `initialize`. So a
// launch that takes twenty seconds froze every other MCP message on this
// desktop for twenty seconds: the agent's own follow-up calls, a second
// agent, the website console, and even a brand-new client trying to open a
// session. The batch handlers already clone-and-drop for exactly this
// reason; the single-profile ones did not.
let app_handle = {
let inner = self.inner.lock().await;
inner
.app_handle
.as_ref()
.ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
data: None,
})?
.clone()
};
// Launch a fresh instance, honoring the requested headless mode. The CDP
// port is self-allocated and discovered later via get_cdp_port_for_profile.
crate::browser_runner::launch_browser_profile_impl(
app_handle.clone(),
profile.clone(),
url.map(|s| s.to_string()),
crate::browser_runner::LaunchOptions::automation(None, headless),
)
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to launch browser: {e}"),
data: None,
})?;
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!("Browser profile '{}' launched successfully", profile.name)
}]
}))
}
async fn handle_kill_profile(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
// Stopping profiles programmatically requires the automation capability.
Self::require_capability(
"Killing a profile",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
data: None,
})?;
// Get the profile
let profiles = ProfileManager::instance()
.list_profiles()
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to list profiles: {e}"),
data: None,
})?;
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}"),
data: None,
})?;
// Check if it's a Wayfern profile
if profile.browser != "wayfern" {
return Err(McpError {
code: -32000,
message: "MCP only supports Wayfern profiles".to_string(),
data: None,
});
}
// Get app handle to kill
// The guard is dropped before the await below. Binding `app_handle` as a
// REFERENCE out of `inner` keeps the engine's single global mutex locked
// for the whole operation — and `handle_message` needs that same mutex to
// validate the session on every request, and to serve `initialize`. So a
// launch that takes twenty seconds froze every other MCP message on this
// desktop for twenty seconds: the agent's own follow-up calls, a second
// agent, the website console, and even a brand-new client trying to open a
// session. The batch handlers already clone-and-drop for exactly this
// reason; the single-profile ones did not.
let app_handle = {
let inner = self.inner.lock().await;
inner
.app_handle
.as_ref()
.ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
data: None,
})?
.clone()
};
// 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}"),
data: None,
})?;
crate::team_lock::release_team_lock_if_needed(profile).await;
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!("Browser profile '{}' stopped successfully", profile.name)
}]
}))
}
async fn handle_batch_run_profiles(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
Self::require_capability(
"Batch launching profiles",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
let profile_ids: Vec<String> = arguments
.get("profile_ids")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_ids array".to_string(),
data: None,
})?;
let url = arguments.get("url").and_then(|v| v.as_str());
if let Some(url) = url {
validate_navigable_url(url)?;
}
let headless = arguments
.get("headless")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let profiles = ProfileManager::instance()
.list_profiles()
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to list profiles: {e}"),
data: None,
})?;
// Clone the app handle and release the lock before the launch loop so we
// never hold the inner mutex across the per-profile awaits.
let app_handle = {
let inner = self.inner.lock().await;
inner
.app_handle
.as_ref()
.ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
data: None,
})?
.clone()
};
let mut launched = 0usize;
let mut lines: Vec<String> = Vec::with_capacity(profile_ids.len());
for profile_id in &profile_ids {
let Some(profile) = profiles.iter().find(|p| p.id.to_string() == *profile_id) else {
lines.push(format!("{profile_id}: not found"));
continue;
};
if profile.browser != "wayfern" {
lines.push(format!(
"{profile_id}: unsupported browser (MCP supports Wayfern)"
));
continue;
}
if let Err(e) = crate::team_lock::acquire_team_lock_if_needed(profile).await {
lines.push(format!("{profile_id}: {e}"));
continue;
}
match crate::browser_runner::launch_browser_profile_impl(
app_handle.clone(),
profile.clone(),
url.map(|s| s.to_string()),
crate::browser_runner::LaunchOptions::automation(None, headless),
)
.await
{
Ok(_) => {
launched += 1;
lines.push(format!("{}: launched", profile.name));
}
Err(e) => lines.push(format!("{}: launch failed: {e}", profile.name)),
}
}
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!("Launched {}/{} profile(s):\n{}", launched, profile_ids.len(), lines.join("\n"))
}]
}))
}
async fn handle_batch_stop_profiles(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
Self::require_capability(
"Batch stopping profiles",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
let profile_ids: Vec<String> = arguments
.get("profile_ids")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_ids array".to_string(),
data: None,
})?;
let profiles = ProfileManager::instance()
.list_profiles()
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to list profiles: {e}"),
data: None,
})?;
let app_handle = {
let inner = self.inner.lock().await;
inner
.app_handle
.as_ref()
.ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
data: None,
})?
.clone()
};
let mut stopped = 0usize;
let mut lines: Vec<String> = Vec::with_capacity(profile_ids.len());
for profile_id in &profile_ids {
let Some(profile) = profiles.iter().find(|p| p.id.to_string() == *profile_id) else {
lines.push(format!("{profile_id}: not found"));
continue;
};
match crate::browser_runner::BrowserRunner::instance()
.kill_browser_process(app_handle.clone(), profile)
.await
{
Ok(_) => {
crate::team_lock::release_team_lock_if_needed(profile).await;
stopped += 1;
lines.push(format!("{}: stopped", profile.name));
}
Err(e) => lines.push(format!("{}: stop failed: {e}", profile.name)),
}
}
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!("Stopped {}/{} profile(s):\n{}", stopped, profile_ids.len(), lines.join("\n"))
}]
}))
}
async fn handle_create_profile(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let name = arguments
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing name".to_string(),
data: None,
})?;
let browser = arguments
.get("browser")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing browser".to_string(),
data: None,
})?;
if browser != "wayfern" {
return Err(McpError {
code: -32602,
message: "browser must be 'wayfern'".to_string(),
data: None,
});
}
let proxy_id = arguments
.get("proxy_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let launch_hook = arguments
.get("launch_hook")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let group_id = arguments
.get("group_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let tags: Option<Vec<String>> = arguments.get("tags").and_then(|v| {
v.as_array().map(|arr| {
arr
.iter()
.filter_map(|item| item.as_str().map(|s| s.to_string()))
.collect()
})
});
// Pick the latest downloaded version for this browser
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
let mut versions = registry.get_downloaded_versions(browser);
versions.sort_by(|a, b| crate::api_client::compare_versions(b, a));
let version = versions.first().ok_or_else(|| McpError {
code: -32000,
message: format!("No downloaded version found for {browser}. Download it first."),
data: None,
})?;
// The guard is dropped before the await below. Binding `app_handle` as a
// REFERENCE out of `inner` keeps the engine's single global mutex locked
// for the whole operation — and `handle_message` needs that same mutex to
// validate the session on every request, and to serve `initialize`. So a
// launch that takes twenty seconds froze every other MCP message on this
// desktop for twenty seconds: the agent's own follow-up calls, a second
// agent, the website console, and even a brand-new client trying to open a
// session. The batch handlers already clone-and-drop for exactly this
// reason; the single-profile ones did not.
let app_handle = {
let inner = self.inner.lock().await;
inner
.app_handle
.as_ref()
.ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
data: None,
})?
.clone()
};
if let Some(status_id) = arguments
.get("status_id")
.and_then(|v| v.as_str())
.filter(|id| !id.is_empty() && !crate::profile_statuses::exists(id))
{
return Err(McpError {
code: -32602,
message: format!("Unknown status id: {status_id}"),
data: None,
});
}
let start_urls = arguments
.get("start_urls")
.and_then(|v| v.as_array())
.map(|urls| {
crate::profile::manager::checked_start_urls(
urls
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect(),
)
})
.transpose()
.map_err(|e| McpError {
code: -32602,
message: format!("Invalid start pages: {e}"),
data: None,
})?;
let temporary = arguments
.get("temporary")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let ephemeral = temporary
|| arguments
.get("ephemeral")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let mut profile = ProfileManager::instance()
.create_profile_with_group(
&app_handle,
name,
browser,
version,
"stable",
proxy_id,
None,
None,
group_id,
ephemeral,
None,
arguments
.get("dns_server")
.and_then(|v| v.as_str())
.map(str::to_string),
launch_hook,
)
.await
.map_err(|e| {
let message = e.to_string();
if message.contains("PROFILE_GENERATION_LIMIT_REACHED") {
let _ = crate::events::emit_empty("profile-generation-limit-reached");
}
log::warn!("MCP profile create failed err=\"{message}\"");
McpError {
code: -32000,
message: format!("Failed to create profile: {message}"),
data: None,
}
})?;
if temporary {
profile = ProfileManager::instance()
.mark_profile_temporary(&profile.id.to_string())
.map_err(|e| McpError {
code: -32000,
message: format!("Profile created but could not be marked temporary: {e}"),
data: None,
})?;
}
let profile_id = profile.id.to_string();
let mut not_applied: Vec<(&str, String)> = Vec::new();
if let Some(start_urls) = start_urls.filter(|urls| !urls.is_empty()) {
if let Err(e) = ProfileManager::instance().update_profile_start_urls(&profile_id, start_urls)
{
not_applied.push(("start_urls", e.to_string()));
}
}
if let Some(status_id) = arguments
.get("status_id")
.and_then(|v| v.as_str())
.filter(|id| !id.is_empty())
{
if let Err(e) = ProfileManager::instance().set_profiles_status(
std::slice::from_ref(&profile_id),
Some(status_id.to_string()),
) {
not_applied.push(("status_id", e.to_string()));
}
}
if let Some(tags) = tags {
match ProfileManager::instance().update_profile_tags(&app_handle, &profile_id, tags) {
Ok(updated) => {
profile.tags = updated.tags;
if let Ok(profiles) = ProfileManager::instance().list_profiles() {
let _ = crate::tag_manager::TAG_MANAGER
.lock()
.map(|manager| manager.rebuild_from_profiles(&profiles));
}
}
Err(e) => not_applied.push(("tags", e.to_string())),
}
}
if !not_applied.is_empty() {
let fields: Vec<&str> = not_applied.iter().map(|(field, _)| *field).collect();
log::warn!(
"MCP profile created with settings not applied profile={profile_id} fields={}",
fields.join(",")
);
}
Ok(created_profile_result(
&profile.name,
&profile_id,
&not_applied,
))
}
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(),
data: None,
})?;
// The guard is dropped before the await below. Binding `app_handle` as a
// REFERENCE out of `inner` keeps the engine's single global mutex locked
// for the whole operation — and `handle_message` needs that same mutex to
// validate the session on every request, and to serve `initialize`. So a
// launch that takes twenty seconds froze every other MCP message on this
// desktop for twenty seconds: the agent's own follow-up calls, a second
// agent, the website console, and even a brand-new client trying to open a
// session. The batch handlers already clone-and-drop for exactly this
// reason; the single-profile ones did not.
let app_handle = {
let inner = self.inner.lock().await;
inner
.app_handle
.as_ref()
.ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
data: None,
})?
.clone()
};
let pm = ProfileManager::instance();
// Agents often send every optional field; an empty name means "unchanged".
if let Some(new_name) = arguments
.get("name")
.and_then(|v| v.as_str())
.filter(|name| !name.trim().is_empty())
{
pm.rename_profile(&app_handle, profile_id, new_name)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to rename profile: {e}"),
data: None,
})?;
}
if let Some(proxy_id) = arguments.get("proxy_id").and_then(|v| v.as_str()) {
// An empty id detaches the proxy; the manager normalizes it.
pm.update_profile_proxy(app_handle.clone(), profile_id, Some(proxy_id.to_string()))
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update proxy: {e}"),
data: None,
})?;
}
if let Some(launch_hook) = arguments.get("launch_hook").and_then(|v| v.as_str()) {
let normalized = if launch_hook.is_empty() {
None
} else {
Some(launch_hook.to_string())
};
pm.update_profile_launch_hook(&app_handle, profile_id, normalized)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update launch hook: {e}"),
data: None,
})?;
}
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}"),
data: None,
})?;
}
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}"),
data: None,
})?;
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}"),
data: None,
})?;
}
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}"),
data: None,
})?;
}
if let Some(clear_on_close) = arguments.get("clear_on_close").and_then(|v| v.as_bool()) {
pm.update_profile_clear_on_close(&app_handle, profile_id, clear_on_close)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update clear-on-close: {e}"),
data: None,
})?;
}
if let Some(urls) = arguments.get("start_urls").and_then(|v| v.as_array()) {
let urls = urls
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect();
pm.update_profile_start_urls(profile_id, urls)
.map_err(|e| McpError {
code: -32602,
message: format!("Failed to update start pages: {e}"),
data: None,
})?;
}
if let Some(status_id) = arguments.get("status_id").and_then(|v| v.as_str()) {
let status_id = Some(status_id.to_string()).filter(|id| !id.is_empty());
if status_id
.as_deref()
.is_some_and(|id| !crate::profile_statuses::exists(id))
{
return Err(McpError {
code: -32602,
message: format!("Unknown status id: {}", status_id.unwrap_or_default()),
data: None,
});
}
pm.set_profiles_status(&[profile_id.to_string()], status_id)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update status: {e}"),
data: None,
})?;
}
if let Some(dns_server) = arguments.get("dns_server").and_then(|v| v.as_str()) {
pm.update_profile_dns_server(profile_id, Some(dns_server.to_string()))
.map_err(|e| McpError {
code: -32602,
message: format!("Failed to update DNS server: {e}"),
data: None,
})?;
}
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(),
data: None,
})?;
// The guard is dropped before the await below. Binding `app_handle` as a
// REFERENCE out of `inner` keeps the engine's single global mutex locked
// for the whole operation — and `handle_message` needs that same mutex to
// validate the session on every request, and to serve `initialize`. So a
// launch that takes twenty seconds froze every other MCP message on this
// desktop for twenty seconds: the agent's own follow-up calls, a second
// agent, the website console, and even a brand-new client trying to open a
// session. The batch handlers already clone-and-drop for exactly this
// reason; the single-profile ones did not.
let app_handle = {
let inner = self.inner.lock().await;
inner
.app_handle
.as_ref()
.ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
data: None,
})?
.clone()
};
ProfileManager::instance()
.delete_profile(&app_handle, profile_id)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to delete profile: {e}"),
data: None,
})?;
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}"),
data: None,
})?
.get_all_tags()
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to get tags: {e}"),
data: None,
})?;
Self::json_content(&tags)
}
async fn handle_list_proxies(&self) -> Result<serde_json::Value, McpError> {
let mut proxies =
serde_json::to_value(PROXY_MANAGER.get_stored_proxies()).map_err(|e| McpError {
code: -32000,
message: format!("Failed to serialize proxies: {e}"),
data: None,
})?;
for proxy in proxies.as_array_mut().into_iter().flatten() {
redact_proxy_secrets(proxy);
}
Self::json_content(&proxies)
}
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(),
data: None,
})?;
// Get the profile
let profiles = ProfileManager::instance()
.list_profiles()
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to list profiles: {e}"),
data: None,
})?;
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}"),
data: None,
})?;
// Check if it's a Wayfern profile
if profile.browser != "wayfern" {
return Err(McpError {
code: -32000,
message: "MCP only supports Wayfern profiles".to_string(),
data: None,
});
}
// "Running" has to mean "drivable", not "has a process on this machine".
// A profile open on the leased fleet has no local process, and answering
// `is_running: false` for it tells an agent not to bother calling the very
// tools that would have worked.
let is_running_locally = profile.process_id.is_some();
let remote_session = if is_running_locally {
None
} else {
crate::remote_session::live_session_for_profile(profile_id).await
};
let location = match (is_running_locally, &remote_session) {
(true, _) => "local",
(false, Some(_)) => "remote",
(false, None) => "stopped",
};
// Whether a LOCAL launch would be refused, and why. An agent that reads
// `location: "stopped"` and calls `run_profile` on a profile whose finished
// remote session has not been pulled back would get a bare 409 with nothing
// to act on; worse, before the gate existed it would have got a browser and
// silently destroyed the session's work.
let handoff = crate::remote_handoff::state_for(profile_id);
let can_launch_locally = handoff.is_none() && !is_running_locally;
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": serde_json::json!({
"profile_id": profile_id,
"is_running": location != "stopped",
"is_running_locally": is_running_locally,
"location": location,
"remote_session_id": remote_session.map(|session| session.session_id),
"can_launch_locally": can_launch_locally,
"local_launch_blocked_by": match handoff {
Some(crate::remote_handoff::HandoffState::Running) => Some("remote_session_running"),
Some(crate::remote_handoff::HandoffState::PendingSync) => {
Some("remote_session_changes_downloading")
}
None => None,
},
}).to_string()
}]
}))
}
// Group management handlers
async fn handle_list_groups(&self) -> Result<serde_json::Value, McpError> {
let groups = GROUP_MANAGER
.lock()
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to lock group manager: {e}"),
data: None,
})?
.get_all_groups()
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to list groups: {e}"),
data: None,
})?;
Self::json_content(&groups)
}
async fn handle_create_profiles_from_template(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let invalid = |message: &str| McpError {
code: -32602,
message: message.to_string(),
data: None,
};
let template_id = arguments
.get("template_id")
.and_then(|v| v.as_str())
.ok_or_else(|| invalid("Missing template_id"))?;
let count = arguments
.get("count")
.and_then(serde_json::Value::as_u64)
.and_then(|count| u32::try_from(count).ok())
.ok_or_else(|| invalid("count must be a whole number from 1 to 100"))?;
let name = arguments.get("name").and_then(|v| v.as_str());
// Cloned out of the lock before the long await, as in create_profile.
let app_handle = {
let inner = self.inner.lock().await;
inner
.app_handle
.as_ref()
.ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
data: None,
})?
.clone()
};
let result =
crate::profile_templates::create_from_template(&app_handle, template_id, name, count)
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Creating profiles failed: {e}"),
data: None,
})?;
let profiles: Vec<serde_json::Value> = result
.created
.iter()
.map(|profile| serde_json::json!({ "id": profile.id, "name": profile.name }))
.collect();
Self::json_content(&serde_json::json!({
"created": profiles,
"stopped_early": result.error,
}))
}
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(),
data: None,
})?;
let groups = GROUP_MANAGER
.lock()
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to lock group manager: {e}"),
data: None,
})?
.get_all_groups()
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to list groups: {e}"),
data: None,
})?;
let group = groups
.iter()
.find(|g| g.id == group_id)
.ok_or_else(|| McpError {
code: -32000,
message: format!("Group not found: {group_id}"),
data: None,
})?;
Self::json_content(&group)
}
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(),
data: None,
})?;
// The guard is dropped before the await below. Binding `app_handle` as a
// REFERENCE out of `inner` keeps the engine's single global mutex locked
// for the whole operation — and `handle_message` needs that same mutex to
// validate the session on every request, and to serve `initialize`. So a
// launch that takes twenty seconds froze every other MCP message on this
// desktop for twenty seconds: the agent's own follow-up calls, a second
// agent, the website console, and even a brand-new client trying to open a
// session. The batch handlers already clone-and-drop for exactly this
// reason; the single-profile ones did not.
let app_handle = {
let inner = self.inner.lock().await;
inner
.app_handle
.as_ref()
.ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
data: None,
})?
.clone()
};
let group = GROUP_MANAGER
.lock()
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to lock group manager: {e}"),
data: None,
})?
.create_group(&app_handle, name.to_string())
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to create group: {e}"),
data: None,
})?;
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(),
data: None,
})?;
let name = arguments
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing name".to_string(),
data: None,
})?;
// The guard is dropped before the await below. Binding `app_handle` as a
// REFERENCE out of `inner` keeps the engine's single global mutex locked
// for the whole operation — and `handle_message` needs that same mutex to
// validate the session on every request, and to serve `initialize`. So a
// launch that takes twenty seconds froze every other MCP message on this
// desktop for twenty seconds: the agent's own follow-up calls, a second
// agent, the website console, and even a brand-new client trying to open a
// session. The batch handlers already clone-and-drop for exactly this
// reason; the single-profile ones did not.
let app_handle = {
let inner = self.inner.lock().await;
inner
.app_handle
.as_ref()
.ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
data: None,
})?
.clone()
};
let group = GROUP_MANAGER
.lock()
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to lock group manager: {e}"),
data: None,
})?
.update_group(&app_handle, group_id.to_string(), name.to_string())
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update group: {e}"),
data: None,
})?;
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(),
data: None,
})?;
// The guard is dropped before the await below. Binding `app_handle` as a
// REFERENCE out of `inner` keeps the engine's single global mutex locked
// for the whole operation — and `handle_message` needs that same mutex to
// validate the session on every request, and to serve `initialize`. So a
// launch that takes twenty seconds froze every other MCP message on this
// desktop for twenty seconds: the agent's own follow-up calls, a second
// agent, the website console, and even a brand-new client trying to open a
// session. The batch handlers already clone-and-drop for exactly this
// reason; the single-profile ones did not.
let app_handle = {
let inner = self.inner.lock().await;
inner
.app_handle
.as_ref()
.ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
data: None,
})?
.clone()
};
GROUP_MANAGER
.lock()
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to lock group manager: {e}"),
data: None,
})?
.delete_group(&app_handle, group_id.to_string())
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to delete group: {e}"),
data: None,
})?;
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(),
data: None,
})?
.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());
// The guard is dropped before the await below. Binding `app_handle` as a
// REFERENCE out of `inner` keeps the engine's single global mutex locked
// for the whole operation — and `handle_message` needs that same mutex to
// validate the session on every request, and to serve `initialize`. So a
// launch that takes twenty seconds froze every other MCP message on this
// desktop for twenty seconds: the agent's own follow-up calls, a second
// agent, the website console, and even a brand-new client trying to open a
// session. The batch handlers already clone-and-drop for exactly this
// reason; the single-profile ones did not.
let app_handle = {
let inner = self.inner.lock().await;
inner
.app_handle
.as_ref()
.ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
data: None,
})?
.clone()
};
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}"),
data: None,
})?;
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)
}]
}))
}
async fn handle_distribute_proxies(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let pairs: Vec<crate::proxy_distribution::ProxyPair> = arguments
.get("pairs")
.cloned()
.map(serde_json::from_value)
.transpose()
.map_err(|e| McpError {
code: -32602,
message: format!("Invalid pairs: {e}"),
data: None,
})?
.ok_or_else(|| McpError {
code: -32602,
message: "Missing pairs".to_string(),
data: None,
})?;
// Same reason the batch handlers clone and drop: the engine's single global
// mutex serves every other MCP message, and fifty profile writes must not
// hold it.
let app_handle = {
let inner = self.inner.lock().await;
inner
.app_handle
.as_ref()
.ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
data: None,
})?
.clone()
};
let results = crate::proxy_distribution::apply_pairs(app_handle, &pairs).await;
let assigned = results.iter().filter(|result| result.ok).count();
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!(
"{assigned} of {} profile(s) assigned a proxy:\n{}",
results.len(),
serde_json::to_string(&results).unwrap_or_default()
)
}]
}))
}
// 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(),
data: None,
})?;
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}"),
data: None,
})?;
let mut proxy = serde_json::to_value(proxy).map_err(|e| McpError {
code: -32000,
message: format!("Failed to serialize proxy: {e}"),
data: None,
})?;
redact_proxy_secrets(&mut proxy);
Self::json_content(&proxy)
}
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(),
data: None,
})?;
// The guard is dropped before the await below. Binding `app_handle` as a
// REFERENCE out of `inner` keeps the engine's single global mutex locked
// for the whole operation — and `handle_message` needs that same mutex to
// validate the session on every request, and to serve `initialize`. So a
// launch that takes twenty seconds froze every other MCP message on this
// desktop for twenty seconds: the agent's own follow-up calls, a second
// agent, the website console, and even a brand-new client trying to open a
// session. The batch handlers already clone-and-drop for exactly this
// reason; the single-profile ones did not.
let app_handle = {
let inner = self.inner.lock().await;
inner
.app_handle
.as_ref()
.ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
data: None,
})?
.clone()
};
let proxy_type = arguments
.get("proxy_type")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing proxy_type".to_string(),
data: None,
})?;
// The tool schema declares an enum, but JSON-Schema enums are advisory only;
// enforce it here so a bad value can't produce a non-functional proxy.
if !MCP_PROXY_TYPES.contains(&proxy_type) {
return Err(McpError {
code: -32602,
message: format!("proxy_type must be one of: {}", MCP_PROXY_TYPES.join(", ")),
data: None,
});
}
let vless_uri = arguments
.get("vless_uri")
.and_then(|value| value.as_str())
.map(str::to_string);
let (host, port) = if crate::xray::is_xray_proxy_type(proxy_type) {
if vless_uri.is_none() {
return Err(McpError {
code: -32602,
message: "Missing vless_uri".to_string(),
data: None,
});
}
(String::new(), 1)
} else {
let host = arguments
.get("host")
.and_then(|value| value.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing host".to_string(),
data: None,
})?
.to_string();
let port = arguments
.get("port")
.and_then(|value| value.as_u64())
.and_then(|port| u16::try_from(port).ok())
.filter(|port| *port != 0)
.ok_or_else(|| McpError {
code: -32602,
message: "Missing or invalid port".to_string(),
data: None,
})?;
(host, port)
};
let username = arguments
.get("username")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let password = arguments
.get("password")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let proxy_settings = ProxySettings {
proxy_type: proxy_type.to_string(),
host,
port,
username,
password,
vless_uri,
};
let proxy = PROXY_MANAGER
.create_stored_proxy(&app_handle, name.to_string(), proxy_settings)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to create proxy: {e}"),
data: None,
})?;
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(),
data: None,
})?;
let name = arguments
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// Build proxy_settings if any settings fields are provided
let has_settings = arguments.get("proxy_type").is_some()
|| arguments.get("host").is_some()
|| arguments.get("port").is_some()
|| arguments.get("username").is_some()
|| arguments.get("password").is_some()
|| arguments.get("vless_uri").is_some();
let proxy_settings = if has_settings {
// Get existing proxy to use as defaults
let proxies = PROXY_MANAGER.get_stored_proxies();
let existing = proxies
.iter()
.find(|p| p.id == proxy_id)
.ok_or_else(|| McpError {
code: -32000,
message: format!("Proxy not found: {proxy_id}"),
data: None,
})?;
let proxy_type = arguments
.get("proxy_type")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| existing.proxy_settings.proxy_type.clone());
if !MCP_PROXY_TYPES.contains(&proxy_type.as_str()) {
return Err(McpError {
code: -32602,
message: format!("proxy_type must be one of: {}", MCP_PROXY_TYPES.join(", ")),
data: None,
});
}
let host = arguments
.get("host")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| existing.proxy_settings.host.clone());
let port = match arguments.get("port") {
Some(value) => value
.as_u64()
.and_then(|port| u16::try_from(port).ok())
.filter(|port| *port != 0)
.ok_or_else(|| McpError {
code: -32602,
message: "Invalid port".to_string(),
data: None,
})?,
None => existing.proxy_settings.port,
};
let username = arguments
.get("username")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| existing.proxy_settings.username.clone());
let password = arguments
.get("password")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| existing.proxy_settings.password.clone());
let vless_uri = arguments
.get("vless_uri")
.and_then(|value| value.as_str())
.map(str::to_string)
.or_else(|| existing.proxy_settings.vless_uri.clone());
Some(ProxySettings {
proxy_type,
host,
port,
username,
password,
vless_uri,
})
} else {
None
};
// The guard is dropped before the await below. Binding `app_handle` as a
// REFERENCE out of `inner` keeps the engine's single global mutex locked
// for the whole operation — and `handle_message` needs that same mutex to
// validate the session on every request, and to serve `initialize`. So a
// launch that takes twenty seconds froze every other MCP message on this
// desktop for twenty seconds: the agent's own follow-up calls, a second
// agent, the website console, and even a brand-new client trying to open a
// session. The batch handlers already clone-and-drop for exactly this
// reason; the single-profile ones did not.
let app_handle = {
let inner = self.inner.lock().await;
inner
.app_handle
.as_ref()
.ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
data: None,
})?
.clone()
};
let proxy = PROXY_MANAGER
.update_stored_proxy(&app_handle, proxy_id, name, proxy_settings)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update proxy: {e}"),
data: None,
})?;
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(),
data: None,
})?;
// The guard is dropped before the await below. Binding `app_handle` as a
// REFERENCE out of `inner` keeps the engine's single global mutex locked
// for the whole operation — and `handle_message` needs that same mutex to
// validate the session on every request, and to serve `initialize`. So a
// launch that takes twenty seconds froze every other MCP message on this
// desktop for twenty seconds: the agent's own follow-up calls, a second
// agent, the website console, and even a brand-new client trying to open a
// session. The batch handlers already clone-and-drop for exactly this
// reason; the single-profile ones did not.
let app_handle = {
let inner = self.inner.lock().await;
inner
.app_handle
.as_ref()
.ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
data: None,
})?
.clone()
};
PROXY_MANAGER
.delete_stored_proxy(&app_handle, proxy_id)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to delete proxy: {e}"),
data: None,
})?;
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(),
data: None,
})?;
let content = match format {
"json" => PROXY_MANAGER.export_proxies_json().map_err(|e| McpError {
code: -32000,
message: format!("Failed to export proxies: {e}"),
data: None,
})?,
"txt" => PROXY_MANAGER.export_proxies_txt(),
_ => {
return Err(McpError {
code: -32602,
message: format!("Invalid format '{}', must be 'json' or 'txt'", format),
data: None,
})
}
};
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(),
data: None,
})?;
let format = arguments
.get("format")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing format".to_string(),
data: None,
})?;
let name_prefix = arguments
.get("name_prefix")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// The guard is dropped before the await below. Binding `app_handle` as a
// REFERENCE out of `inner` keeps the engine's single global mutex locked
// for the whole operation — and `handle_message` needs that same mutex to
// validate the session on every request, and to serve `initialize`. So a
// launch that takes twenty seconds froze every other MCP message on this
// desktop for twenty seconds: the agent's own follow-up calls, a second
// agent, the website console, and even a brand-new client trying to open a
// session. The batch handlers already clone-and-drop for exactly this
// reason; the single-profile ones did not.
let app_handle = {
let inner = self.inner.lock().await;
inner
.app_handle
.as_ref()
.ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
data: None,
})?
.clone()
};
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}"),
data: None,
})?,
"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(),
data: None,
});
}
PROXY_MANAGER
.import_proxies_from_parsed(&app_handle, parsed, name_prefix)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to import proxies: {e}"),
data: None,
})?
}
_ => {
return Err(McpError {
code: -32602,
message: format!("Invalid format '{}', must be 'json' or 'txt'", format),
data: None,
})
}
};
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!(
"Import complete: {} imported, {} skipped, {} errors",
result.imported_count,
result.skipped_count,
result.errors.len()
)
}]
}))
}
// Profile import handlers
async fn handle_detect_browser_profiles(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let importer = crate::profile_importer::ProfileImporter::instance();
let profiles = match arguments.get("folder").and_then(|v| v.as_str()) {
Some(folder) => importer.scan_folder(std::path::Path::new(folder)),
None => importer.detect_existing_profiles(),
}
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to detect profiles: {e}"),
data: None,
})?;
Self::json_content(&profiles)
}
async fn handle_import_browser_profiles(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let items: Vec<crate::profile_importer::ImportProfileItem> = arguments
.get("items")
.cloned()
.ok_or_else(|| McpError {
code: -32602,
message: "Missing items".to_string(),
data: None,
})
.and_then(|v| {
serde_json::from_value(v).map_err(|e| McpError {
code: -32602,
message: format!("Invalid items: {e}"),
data: None,
})
})?;
let group_id = arguments
.get("group_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let duplicate_strategy = arguments
.get("duplicate_strategy")
.cloned()
.map(serde_json::from_value::<crate::profile_importer::DuplicateStrategy>)
.transpose()
.map_err(|e| McpError {
code: -32602,
message: format!("Invalid duplicate_strategy: {e}"),
data: None,
})?
.unwrap_or_default();
// Clone the handle instead of holding the inner lock across a potentially
// multi-GB copy.
let app_handle = {
let inner = self.inner.lock().await;
inner.app_handle.clone().ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
data: None,
})?
};
let result = crate::profile_importer::ProfileImporter::instance()
.import_profiles(&app_handle, items, group_id, duplicate_strategy, None)
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to import profiles: {e}"),
data: None,
})?;
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!(
"Import complete: {} imported, {} skipped, {} failed\n{}",
result.imported_count,
result.skipped_count,
result.failed_count,
serde_json::to_string(&result.results).unwrap_or_default()
)
}]
}))
}
// Cookie management handlers
async fn handle_import_profile_cookies(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
data: None,
})?;
let content = arguments
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing content".to_string(),
data: None,
})?;
let app_handle = {
let inner = self.inner.lock().await;
inner
.app_handle
.as_ref()
.ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
data: None,
})?
.clone()
};
let result =
crate::cookie_manager::CookieManager::import_cookies(&app_handle, profile_id, content)
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to import cookies: {e}"),
data: None,
})?;
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let profile_manager = crate::profile::manager::ProfileManager::instance();
if let Ok(profiles) = profile_manager.list_profiles() {
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == profile_id) {
if profile.is_sync_enabled() {
let pid = profile_id.to_string();
tauri::async_runtime::spawn(async move {
scheduler.queue_profile_sync(pid).await;
});
}
}
}
}
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!(
"Import complete: {} imported, {} replaced, {} parse error(s)",
result.cookies_imported,
result.cookies_replaced,
result.errors.len()
)
}]
}))
}
// VPN management handlers
async fn handle_import_vpn(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let content = arguments
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing content".to_string(),
data: None,
})?;
let filename = arguments
.get("filename")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing filename".to_string(),
data: None,
})?;
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}"),
data: None,
})?;
let config = storage
.import_config(content, filename, name)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to import VPN config: {e}"),
data: None,
})?;
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}"),
data: None,
})?;
let configs = storage.list_configs().map_err(|e| McpError {
code: -32000,
message: format!("Failed to list VPN configs: {e}"),
data: None,
})?;
Self::json_content(&configs)
}
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(),
data: None,
})?;
// 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}"),
data: None,
})?;
storage.delete_config(vpn_id).map_err(|e| McpError {
code: -32000,
message: format!("Failed to delete VPN config: {e}"),
data: None,
})?;
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(),
data: None,
})?;
// 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}"),
data: None,
})?;
// 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}"),
data: None,
})?;
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(),
data: None,
})?;
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}"),
data: None,
})?;
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(),
data: None,
})?;
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,
};
Self::json_content(&status)
}
// 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(),
data: None,
})?;
let profiles = ProfileManager::instance()
.list_profiles()
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to list profiles: {e}"),
data: None,
})?;
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}"),
data: None,
})?;
let fingerprint_info = match profile.browser.as_str() {
"wayfern" => {
let config = profile.wayfern_config.as_ref().cloned().unwrap_or_default();
let fingerprint = crate::wayfern_manager::WayfernManager::instance()
.profile_fingerprint(profile)
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to read the fingerprint: {e}"),
data: None,
})?;
serde_json::json!({
"browser": "wayfern",
"fingerprint": fingerprint,
"identity_id": config.identity_id,
"identity_overrides": config.identity_overrides,
"location": config.location,
"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,
// The launch behaviour an agent can also set through
// `update_profile_fingerprint`, reported here so it never has to
// guess what a profile will do when it starts.
"restore_session": config.restore_session,
"webrtc_mode": config.webrtc_mode,
"camera_file": config.camera_file,
"camera_crop": config.camera_crop,
})
}
_ => {
return Err(McpError {
code: -32000,
message: "MCP only supports Wayfern profiles".to_string(),
data: None,
})
}
};
Self::json_content(&fingerprint_info)
}
async fn handle_update_profile_fingerprint(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
if !CLOUD_AUTH.can_use_cross_os_fingerprints().await {
return Err(McpError {
code: -32000,
message: "Fingerprint editing requires a plan that includes it".to_string(),
data: None,
});
}
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(),
data: None,
})?;
let fingerprint = arguments.get("fingerprint").and_then(|v| v.as_str());
let os = arguments.get("os").and_then(|v| v.as_str());
let restore_session = arguments
.get("restore_session")
.and_then(serde_json::Value::as_bool);
let webrtc_mode = match arguments.get("webrtc_mode").and_then(|v| v.as_str()) {
Some(mode) if crate::wayfern_manager::WebRtcMode::parse(mode).is_some() => {
Some(mode.to_string())
}
Some(mode) => {
return Err(McpError {
code: -32602,
message: format!("Unknown webrtc_mode {mode:?}; expected auto, tcp_only or block"),
data: None,
})
}
None => None,
};
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
),
data: None,
});
}
}
let profiles = ProfileManager::instance()
.list_profiles()
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to list profiles: {e}"),
data: None,
})?;
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}"),
data: None,
})?;
// The guard is dropped before the await below. Binding `app_handle` as a
// REFERENCE out of `inner` keeps the engine's single global mutex locked
// for the whole operation — and `handle_message` needs that same mutex to
// validate the session on every request, and to serve `initialize`. So a
// launch that takes twenty seconds froze every other MCP message on this
// desktop for twenty seconds: the agent's own follow-up calls, a second
// agent, the website console, and even a brand-new client trying to open a
// session. The batch handlers already clone-and-drop for exactly this
// reason; the single-profile ones did not.
let app_handle = {
let inner = self.inner.lock().await;
inner
.app_handle
.as_ref()
.ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
data: None,
})?
.clone()
};
match profile.browser.as_str() {
"wayfern" => {
let mut config = profile.wayfern_config.as_ref().cloned().unwrap_or_default();
if let Some(fp) = fingerprint {
config.fingerprint = Some(fp.to_string());
}
if let Some(os_val) = os {
config.os = Some(os_val.to_string());
}
if let Some(r) = randomize {
config.randomize_fingerprint_on_launch = Some(r);
}
if let Some(restore) = restore_session {
config.restore_session = Some(restore);
}
if let Some(mode) = webrtc_mode {
config.webrtc_mode = Some(mode);
}
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}"),
data: None,
})?;
}
_ => {
return Err(McpError {
code: -32000,
message: "MCP only supports Wayfern profiles".to_string(),
data: None,
})
}
}
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(),
data: None,
})?;
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(),
data: None,
})?
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
// The guard is dropped before the await below. Binding `app_handle` as a
// REFERENCE out of `inner` keeps the engine's single global mutex locked
// for the whole operation — and `handle_message` needs that same mutex to
// validate the session on every request, and to serve `initialize`. So a
// launch that takes twenty seconds froze every other MCP message on this
// desktop for twenty seconds: the agent's own follow-up calls, a second
// agent, the website console, and even a brand-new client trying to open a
// session. The batch handlers already clone-and-drop for exactly this
// reason; the single-profile ones did not.
let app_handle = {
let inner = self.inner.lock().await;
inner
.app_handle
.as_ref()
.ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
data: None,
})?
.clone()
};
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}"),
data: None,
})?;
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!(
"Proxy bypass rules updated for profile '{}': {} rule(s) configured",
profile.name,
rules.len()
)
}]
}))
}
async fn handle_update_profile_dns_blocklist(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
data: None,
})?;
let level = arguments
.get("level")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing level".to_string(),
data: None,
})?;
let dns_blocklist = if level == "none" {
None
} else {
Some(level.to_string())
};
let profile = ProfileManager::instance()
.update_profile_dns_blocklist(profile_id, dns_blocklist)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update DNS blocklist: {e}"),
data: None,
})?;
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!(
"DNS blocklist updated for profile '{}': {}",
profile.name,
level
)
}]
}))
}
async fn handle_get_dns_blocklist_status(&self) -> Result<serde_json::Value, McpError> {
let statuses = crate::dns_blocklist::BlocklistManager::get_cache_status();
Self::json_content(&statuses)
}
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(),
data: None,
});
}
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}"),
data: None,
})?;
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(),
data: None,
});
}
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}"),
data: None,
})?;
Ok(serde_json::to_value(groups).unwrap())
}
async fn handle_add_extension(
&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(),
data: None,
});
}
let path = arguments
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing required parameter: path".to_string(),
data: None,
})?;
let name = arguments
.get("name")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let link = arguments
.get("link")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let path = crate::extension_manager::client_named_path(path).map_err(|e| McpError {
code: -32602,
message: format!("Invalid path: {e}"),
data: None,
})?;
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let extension = mgr
.add_extension_from_path(name, &path, link)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to add extension: {e}"),
data: None,
})?;
Ok(serde_json::to_value(extension).unwrap())
}
async fn handle_update_extension(
&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(),
data: None,
});
}
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(),
data: None,
})?;
let name = arguments
.get("name")
.and_then(|v| v.as_str())
.map(str::to_string);
let path = arguments.get("path").and_then(|v| v.as_str());
if name.is_none() && path.is_none() {
return Err(McpError {
code: -32602,
message: "Provide at least one of: name, path".to_string(),
data: None,
});
}
let link = arguments
.get("link")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let path = path
.map(|path| {
crate::extension_manager::client_named_path(path).map_err(|e| McpError {
code: -32602,
message: format!("Invalid path: {e}"),
data: None,
})
})
.transpose()?;
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let extension = match path {
Some(path) => mgr.update_extension_from_path(extension_id, name, &path, link),
None => mgr.update_extension(extension_id, name, None, None),
}
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update extension: {e}"),
data: None,
})?;
Ok(serde_json::to_value(extension).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(),
data: None,
});
}
let name = arguments
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing required parameter: name".to_string(),
data: None,
})?;
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}"),
data: None,
})?;
Ok(serde_json::to_value(group).unwrap())
}
async fn handle_update_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(),
data: None,
});
}
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(),
data: None,
})?;
let name = arguments
.get("name")
.and_then(|v| v.as_str())
.map(str::to_string);
let extension_ids = arguments
.get("extension_ids")
.and_then(|v| v.as_array())
.map(|ids| {
ids
.iter()
.filter_map(|id| id.as_str().map(str::to_string))
.collect::<Vec<String>>()
});
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let group = mgr
.update_group(group_id, name, extension_ids)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update extension group: {e}"),
data: None,
})?;
Ok(serde_json::to_value(group).unwrap())
}
async fn handle_add_extension_to_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(),
data: None,
});
}
let (group_id, extension_id) = Self::group_and_extension_ids(arguments)?;
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let group = mgr
.add_extension_to_group(group_id, extension_id)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to add extension to group: {e}"),
data: None,
})?;
Ok(serde_json::to_value(group).unwrap())
}
async fn handle_remove_extension_from_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(),
data: None,
});
}
let (group_id, extension_id) = Self::group_and_extension_ids(arguments)?;
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let group = mgr
.remove_extension_from_group(group_id, extension_id)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to remove extension from group: {e}"),
data: None,
})?;
Ok(serde_json::to_value(group).unwrap())
}
fn group_and_extension_ids(arguments: &serde_json::Value) -> Result<(&str, &str), McpError> {
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(),
data: None,
})?;
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(),
data: None,
})?;
Ok((group_id, extension_id))
}
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(),
data: None,
});
}
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(),
data: None,
})?;
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}"),
data: None,
})?;
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(),
data: None,
});
}
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(),
data: None,
})?;
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}"),
data: None,
})?;
if let Err(e) = crate::events::emit_empty("extensions-changed") {
log::warn!("Event not emitted event=extensions-changed err=\"{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(),
data: None,
});
}
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(),
data: None,
})?;
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}"),
data: None,
})?;
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"),
data: None,
})?;
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}"),
data: None,
})?;
}
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}"),
data: None,
})?;
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(),
data: None,
});
}
let locks = crate::team_lock::TEAM_LOCK.get_locks().await;
Self::json_content(&locks)
}
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(),
data: None,
});
}
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(),
data: None,
})?;
let lock_status = crate::team_lock::TEAM_LOCK
.get_lock_status(profile_id)
.await;
Self::json_content(&lock_status)
}
// --- CDP utility methods for browser interaction ---
/// Where this profile's browser is: on this machine, or on the fleet.
///
/// Every interaction tool goes through here, so a profile launched with
/// `run-remote` is driven by exactly the tools that drive a local one. The
/// alternative — a parallel set of remote-only tools — drifts from the local
/// set within a release and doubles every future change.
async fn resolve_cdp_target(&self, profile_id: &str) -> Result<CdpTarget, McpError> {
let profile = self.get_wayfern_profile(profile_id)?;
crate::cdp_target::resolve(&profile)
.await
.map_err(|e| McpError {
code: -32000,
message: e.to_string(),
data: None,
})
}
async fn send_cdp(
&self,
target: &CdpTarget,
method: &str,
params: serde_json::Value,
) -> Result<serde_json::Value, McpError> {
crate::cdp_target::run_command(target, method, params)
.await
.map_err(cdp_error)
}
async fn send_planned_keystrokes(
&self,
target: &CdpTarget,
events: &[crate::human_typing::TypingEvent],
) -> Result<(), McpError> {
dispatch_keystrokes(target, events).await.map_err(cdp_error)
}
/// `expect_navigation`: the command is a navigation, so the page's load is
/// always awaited; otherwise only a navigation that starts is.
async fn send_cdp_and_wait_for_load(
&self,
target: &CdpTarget,
method: &str,
params: serde_json::Value,
timeout_secs: u64,
expect_navigation: bool,
) -> Result<serde_json::Value, McpError> {
crate::cdp_target::run_command_awaiting_load(
target,
method,
params,
timeout_secs,
expect_navigation,
)
.await
.map_err(cdp_error)
}
/// The profile a browser-interaction tool refers to.
///
/// Deliberately does NOT require a local process. That check used to live
/// here, and it is exactly the state a profile running on the fleet is in, so
/// it refused every remote tool call before resolution had a chance to find
/// the session. Whether a browser exists at all is [`resolve_cdp_target`]'s
/// answer to give, because only it can see both places one could be.
fn get_wayfern_profile(&self, profile_id: &str) -> Result<BrowserProfile, McpError> {
let profiles = ProfileManager::instance()
.list_profiles()
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to list profiles: {e}"),
data: None,
})?;
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}"),
data: None,
})?;
if profile.browser != "wayfern" {
return Err(McpError {
code: -32000,
message: "MCP only supports Wayfern profiles".to_string(),
data: None,
});
}
Ok(profile)
}
// --- Browser interaction handlers ---
/// Where the page is now, or `None` when it cannot be read.
async fn page_state(&self, target: &CdpTarget) -> Option<PageState> {
let reply = self
.send_cdp(
target,
"Runtime.evaluate",
serde_json::json!({ "expression": PAGE_STATE_SCRIPT, "returnByValue": true }),
)
.await
.ok()?;
serde_json::from_value(reply.pointer("/result/value")?.clone()).ok()
}
/// `report` as the tool's text, followed by where the page is now.
async fn with_page_line(&self, target: &CdpTarget, report: String) -> serde_json::Value {
let text = match self.page_state(target).await {
Some(state) => format!("{report}\n{}", page_report(&state)),
None => report,
};
serde_json::json!({ "content": [{ "type": "text", "text": text }] })
}
async fn layout_metrics(&self, target: &CdpTarget) -> Result<LayoutMetrics, McpError> {
let reply = self
.send_cdp(target, "Page.getLayoutMetrics", serde_json::json!({}))
.await?;
Ok(LayoutMetrics::from_reply(&reply))
}
/// The running profile a tab tool names.
fn tab_profile(arguments: &serde_json::Value) -> Result<BrowserProfile, McpError> {
let profile_id = Self::require_str(arguments, "profile_id")?;
ProfileManager::instance()
.list_profiles()
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to list profiles: {e}"),
data: None,
})?
.into_iter()
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| McpError {
code: -32000,
message: format!("Profile not found: {profile_id}"),
data: None,
})
}
async fn handle_list_tabs(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile = Self::tab_profile(arguments)?;
let tabs = crate::browser_tabs::list(&profile)
.await
.map_err(tab_error)?;
Self::json_content(&serde_json::json!({ "tabs": tabs }))
}
async fn handle_open_tab(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile = Self::tab_profile(arguments)?;
let url = Self::require_navigable_url(arguments, "url")?;
let tab = crate::browser_tabs::open(&profile, url)
.await
.map_err(tab_error)?;
Self::json_content(&tab)
}
async fn handle_switch_tab(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile = Self::tab_profile(arguments)?;
let tab_id = Self::require_str(arguments, "tab_id")?;
let tab = crate::browser_tabs::switch(&profile, tab_id)
.await
.map_err(tab_error)?;
Self::json_content(&tab)
}
async fn handle_close_tab(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile = Self::tab_profile(arguments)?;
let tab_id = Self::require_str(arguments, "tab_id")?;
crate::browser_tabs::close(&profile, tab_id)
.await
.map_err(tab_error)?;
Ok(serde_json::json!({ "content": [{ "type": "text", "text": "Tab closed" }] }))
}
/// The index the caller named, directly or as a stable element id (`e12`)
/// from the last listing.
async fn element_index(
&self,
profile_id: &str,
cache: &str,
arguments: &serde_json::Value,
) -> Result<u64, McpError> {
if let Some(index) = arguments.get("index").and_then(|v| v.as_u64()) {
return Ok(index);
}
let raw = arguments.get("element_id").ok_or_else(|| McpError {
code: -32602,
message: "Missing index or element_id".to_string(),
data: None,
})?;
let id = raw
.as_u64()
.or_else(|| {
raw
.as_str()
.and_then(|s| s.trim_start_matches('e').parse().ok())
})
.ok_or_else(|| McpError {
code: -32602,
message: "element_id is an id like e12 from get_interactive_elements".to_string(),
data: None,
})?;
let target = self.resolve_cdp_target(profile_id).await?;
let reply = self
.send_cdp(
&target,
"Runtime.evaluate",
serde_json::json!({
"expression": format!(
"(() => {{ const arr = window[{cache}]; const ids = window.__donut_element_ids; if (!arr || !ids) return -1; return arr.findIndex((el) => ids.map.get(el) === {id}); }})()"
),
"returnByValue": true,
}),
)
.await?;
match reply.pointer("/result/value").and_then(|v| v.as_i64()) {
Some(index) if index >= 0 => Ok(index as u64),
_ => Err(McpError {
code: -32000,
message: format!(
"No element e{id} in the last listing. Call get_interactive_elements again."
),
data: None,
}),
}
}
/// Several browser actions in one call, in order, on one profile. Stops at
/// the first failure, and by default once the page's address changes, since
/// what follows was planned against the page before it.
async fn handle_run_actions(
&self,
caller: McpCaller<'_>,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = Self::require_str(arguments, "profile_id")?;
let actions = arguments
.get("actions")
.and_then(|v| v.as_array())
.filter(|list| !list.is_empty() && list.len() <= MAX_BATCH_ACTIONS)
.ok_or_else(|| McpError {
code: -32602,
message: format!("actions is a list of 1 to {MAX_BATCH_ACTIONS} steps"),
data: None,
})?;
let mut steps = Vec::with_capacity(actions.len());
for action in actions {
let tool = action
.get("tool")
.and_then(|v| v.as_str())
.unwrap_or_default();
if !BATCH_TOOLS.contains(&tool) {
return Err(McpError {
code: -32602,
message: format!(
"{tool:?} cannot run in a batch. Batchable tools: {}",
BATCH_TOOLS.join(", ")
),
data: None,
});
}
let mut step_arguments = action
.get("arguments")
.cloned()
.filter(|v| v.is_object())
.unwrap_or_else(|| serde_json::json!({}));
step_arguments["profile_id"] = serde_json::json!(profile_id);
steps.push((tool, step_arguments));
}
let stop_on_change = arguments
.get("stop_on_change")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let target = self.resolve_cdp_target(profile_id).await?;
let mut url = self.page_state(&target).await.map(|state| state.url);
let mut results = Vec::new();
let mut stopped: Option<String> = None;
for (position, (tool, step_arguments)) in steps.iter().enumerate() {
if let Err(refusal) = crate::agent_console::check_call(false, step_arguments) {
let error = Self::console_refusal(refusal);
results.push(serde_json::json!({ "tool": tool, "ok": false, "error": error.message }));
stopped = Some("the person paused or took over".to_string());
break;
}
match Box::pin(self.dispatch_tool_call(caller, tool, step_arguments)).await {
Ok(value) => {
let text: String = value
.pointer("/content/0/text")
.and_then(|v| v.as_str())
.unwrap_or_default()
.chars()
.take(BATCH_RESULT_CHARS)
.collect();
results.push(serde_json::json!({ "tool": tool, "ok": true, "result": text }));
}
Err(error) => {
results.push(serde_json::json!({ "tool": tool, "ok": false, "error": error.message }));
stopped = Some("a step failed".to_string());
break;
}
}
if stop_on_change && position + 1 < steps.len() {
let now = self.page_state(&target).await.map(|state| state.url);
if now.is_some() && url.is_some() && now != url {
stopped = Some(format!(
"the page changed to {}",
now.clone().unwrap_or_default()
));
break;
}
url = now;
}
}
let done = results.len();
Self::json_content(&serde_json::json!({
"results": results,
"completed": done,
"remaining": steps.len() - done,
"stopped": stopped,
}))
}
async fn handle_scroll(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = Self::require_str(arguments, "profile_id")?;
let direction = arguments
.get("direction")
.and_then(|v| v.as_str())
.unwrap_or("down");
let pages = arguments
.get("pages")
.and_then(|v| v.as_f64())
.unwrap_or(1.0)
.clamp(0.1, 10.0);
let ctx = self.agent_context(profile_id).await?;
let before = self.layout_metrics(&ctx.target).await?;
let (delta_x, delta_y) = scroll_delta(direction, pages, before.width, before.height)
.ok_or_else(|| McpError {
code: -32602,
message: "direction must be up, down, left or right".to_string(),
data: None,
})?;
if ctx.engine.is_wayfern() {
vellum_wheel(&ctx.target, delta_x, delta_y)
.await
.map_err(AgentError::into_mcp)?;
} else {
self
.send_cdp(
&ctx.target,
"Input.dispatchMouseEvent",
serde_json::json!({
"type": "mouseWheel",
"x": before.width / 2.0,
"y": before.height / 2.0,
"deltaX": delta_x,
"deltaY": delta_y,
}),
)
.await?;
}
tokio::time::sleep(SCROLL_SETTLE).await;
let after = self.layout_metrics(&ctx.target).await?;
Self::json_content(&serde_json::json!({
"moved": (after.y - before.y).abs() > 1.0 || (after.x - before.x).abs() > 1.0,
"scroll_x": after.x.round(),
"scroll_y": after.y.round(),
"viewport_height": after.height.round(),
"page_height": after.page_height.round(),
"at_top": after.y <= 1.0,
"at_bottom": after.y + after.height >= after.page_height - 2.0,
}))
}
async fn handle_press_key(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = Self::require_str(arguments, "profile_id")?;
let name = Self::require_str(arguments, "key")?;
let stroke = key_stroke(name).ok_or_else(|| McpError {
code: -32602,
message: format!("Unknown key: {name}. Use a key name such as Enter, Tab, Escape or ArrowDown, or one character."),
data: None,
})?;
let modifiers: Vec<&str> = arguments
.get("modifiers")
.and_then(|v| v.as_array())
.map(|list| list.iter().filter_map(|m| m.as_str()).collect())
.unwrap_or_default();
let bits = modifier_bits(&modifiers).map_err(|message| McpError {
code: -32602,
message,
data: None,
})?;
let target = self.resolve_cdp_target(profile_id).await?;
// A shortcut (Ctrl or Meta held) types nothing.
let types = stroke.text.is_some() && bits & 0b0110 == 0;
let kind = if types { "keyDown" } else { "rawKeyDown" };
let mut down = serde_json::json!({
"type": kind,
"key": stroke.key,
"code": stroke.code,
"windowsVirtualKeyCode": stroke.key_code,
"nativeVirtualKeyCode": stroke.key_code,
"modifiers": bits,
});
if let Some(text) = stroke.text.as_ref().filter(|_| types) {
down["text"] = serde_json::json!(text);
down["unmodifiedText"] = serde_json::json!(text);
}
let before = self.page_state(&target).await.map(|state| state.url);
// The press is the step that may submit a form, so the wait watches it.
self
.send_cdp_and_wait_for_load(&target, "Input.dispatchKeyEvent", down, 10, false)
.await?;
self
.send_cdp(
&target,
"Input.dispatchKeyEvent",
serde_json::json!({
"type": "keyUp",
"key": stroke.key,
"code": stroke.code,
"windowsVirtualKeyCode": stroke.key_code,
"nativeVirtualKeyCode": stroke.key_code,
"modifiers": bits,
}),
)
.await?;
let state = self.page_state(&target).await;
let mut text = format!("Pressed {name}");
if let Some(state) = state {
if before.is_some_and(|url| url != state.url) {
text.push_str(" (the page changed)");
}
text.push('\n');
text.push_str(&page_report(&state));
}
Ok(serde_json::json!({ "content": [{ "type": "text", "text": text }] }))
}
async fn handle_go_back(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = Self::require_str(arguments, "profile_id")?;
let target = self.resolve_cdp_target(profile_id).await?;
let history = self
.send_cdp(&target, "Page.getNavigationHistory", serde_json::json!({}))
.await?;
let current = history
.get("currentIndex")
.and_then(|v| v.as_u64())
.unwrap_or(0) as usize;
let previous = current
.checked_sub(1)
.and_then(|index| history.pointer(&format!("/entries/{index}/id")))
.and_then(|id| id.as_u64());
let Some(entry_id) = previous else {
return Ok(serde_json::json!({
"content": [{ "type": "text", "text": "There is no earlier page in this tab." }]
}));
};
self
.send_cdp_and_wait_for_load(
&target,
"Page.navigateToHistoryEntry",
serde_json::json!({ "entryId": entry_id }),
30,
true,
)
.await?;
Ok(self.with_page_line(&target, "Went back".to_string()).await)
}
async fn handle_wait_for(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = Self::require_str(arguments, "profile_id")?;
let text = arguments
.get("text")
.and_then(|v| v.as_str())
.filter(|t| !t.is_empty());
let selector = arguments
.get("selector")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty());
let script = wait_for_script(text, selector).ok_or_else(|| McpError {
code: -32602,
message: "Pass text or selector, not both".to_string(),
data: None,
})?;
let gone = arguments
.get("gone")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let timeout_ms = arguments
.get("timeout_ms")
.and_then(|v| v.as_u64())
.unwrap_or(WAIT_FOR_DEFAULT_MS)
.clamp(100, WAIT_FOR_MAX_MS);
let target = self.resolve_cdp_target(profile_id).await?;
let started = std::time::Instant::now();
let deadline = started + Duration::from_millis(timeout_ms);
let met = loop {
let reply = self
.send_cdp(
&target,
"Runtime.evaluate",
serde_json::json!({ "expression": script, "returnByValue": true }),
)
.await;
match reply {
Ok(reply) if reply.get("exceptionDetails").is_some() => {
return Err(McpError {
code: -32602,
message: "The selector is not valid CSS".to_string(),
data: None,
});
}
Ok(reply) => {
let present = reply
.pointer("/result/value")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if present != gone {
break true;
}
}
// A navigation in progress has no page to ask; the next look will.
Err(_) => {}
}
if std::time::Instant::now() >= deadline {
break false;
}
tokio::time::sleep(WAIT_FOR_POLL).await;
};
let state = self.page_state(&target).await.unwrap_or_default();
Self::json_content(&wait_report(met, started.elapsed(), &state))
}
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(),
data: None,
})?;
// Read and validated in one step, so the safe path is the short one.
let url = Self::require_navigable_url(arguments, "url")?;
let target = self.resolve_cdp_target(profile_id).await?;
self
.send_cdp_and_wait_for_load(
&target,
"Page.navigate",
serde_json::json!({ "url": url }),
30,
true,
)
.await?;
Ok(
self
.with_page_line(&target, format!("Navigated to {url}"))
.await,
)
}
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(),
data: None,
})?;
// JPEG by default: an agent reads a screenshot, it does not archive one,
// and a JPEG is a fraction of the PNG's size in the context it fills.
let format = arguments
.get("format")
.and_then(|v| v.as_str())
.unwrap_or("jpeg");
let quality = arguments
.get("quality")
.and_then(|v| v.as_i64())
.or((format != "png").then_some(75));
let full_page = arguments
.get("full_page")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let max_width = arguments
.get("max_width")
.and_then(|v| v.as_u64())
.unwrap_or(1280)
.clamp(320, 4096) as f64;
let target = self.resolve_cdp_target(profile_id).await?;
let mut params = serde_json::json!({ "format": format });
if let Some(q) = quality.filter(|_| format != "png") {
params["quality"] = serde_json::json!(q.clamp(0, 100));
}
let layout = self.layout_metrics(&target).await?;
let (x, y, width, height) = if full_page {
(0.0, 0.0, layout.page_width, layout.page_height)
} else {
(layout.x, layout.y, layout.width, layout.height)
};
params["clip"] = serde_json::json!({
"x": x,
"y": y,
"width": width,
"height": height,
"scale": screenshot_scale(width, layout.dpr, max_width),
});
if full_page {
params["captureBeyondViewport"] = serde_json::json!(true);
}
let result = self
.send_cdp(&target, "Page.captureScreenshot", params)
.await?;
let data = result
.get("data")
.and_then(|v| v.as_str())
.unwrap_or_default();
Ok(serde_json::json!({
"content": [{
"type": "image",
"data": data,
"mimeType": format!("image/{format}")
}]
}))
}
async fn handle_evaluate_javascript(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
data: None,
})?;
let expression = arguments
.get("expression")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing expression".to_string(),
data: None,
})?;
let await_promise = arguments
.get("await_promise")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let wait_for_load = arguments
.get("wait_for_load")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let target = self.resolve_cdp_target(profile_id).await?;
let cdp_params = serde_json::json!({
"expression": expression,
"returnByValue": true,
"awaitPromise": await_promise,
});
let result = if wait_for_load {
self
.send_cdp_and_wait_for_load(&target, "Runtime.evaluate", cdp_params, 30, false)
.await?
} else {
self
.send_cdp(&target, "Runtime.evaluate", cdp_params)
.await?
};
let value = if let Some(exception) = result.get("exceptionDetails") {
let text = exception
.get("text")
.or_else(|| {
exception
.get("exception")
.and_then(|e| e.get("description"))
})
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
serde_json::json!({ "error": text })
} else if let Some(r) = result.get("result") {
let val = r.get("value").cloned().unwrap_or(serde_json::json!(null));
serde_json::json!({ "value": val, "type": r.get("type") })
} else {
serde_json::json!({ "value": null })
};
Self::json_content(&value)
}
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(),
data: None,
})?;
let selector = arguments
.get("selector")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing selector".to_string(),
data: None,
})?;
let ctx = self.agent_context(profile_id).await?;
let selector_escaped = selector.replace('\\', "\\\\").replace('\'', "\\'");
if ctx.engine.is_wayfern() {
// On 152 no script clicks anything. The page only says where the
// element is; the click comes from a real pointer that glides there
// and presses with the profile's own timing, through the same input
// path a mouse uses.
let point = locate_by_script(&ctx.target, element_rect_script(&selector_escaped)).await?;
let (_, navigated) = vellum_click(&ctx.target, point, None, None)
.await
.map_err(AgentError::into_mcp)?;
return Ok(
self
.with_page_line(
&ctx.target,
click_report(&format!("Clicked element: {selector}"), navigated),
)
.await,
);
}
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 no navigation starts, the wait
// ends a moment after the click.
let result = self
.send_cdp_and_wait_for_load(
&ctx.target,
"Runtime.evaluate",
serde_json::json!({
"expression": js,
"returnByValue": true,
}),
10,
false,
)
.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(),
data: None,
});
}
Ok(
self
.with_page_line(&ctx.target, format!("Clicked element: {selector}"))
.await,
)
}
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(),
data: None,
})?;
let selector = arguments
.get("selector")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing selector".to_string(),
data: None,
})?;
let text = arguments
.get("text")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing text".to_string(),
data: None,
})?;
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 typos = arguments
.get("typos")
.and_then(|v| v.as_bool())
.unwrap_or(true);
// The engine is read off the profile before any budget is decided. A 152
// browser types through Vellum, which paces the keys itself, so the plan
// below is only built for the fallback engine.
let profile = self.get_wayfern_profile(profile_id)?;
let humanized = Engine::for_version(&profile.version).is_wayfern() && !instant;
// PLANNED FIRST, before anything touches the page. The planner can refuse
// (TYPING_TOO_LONG), and the focus step below empties the field when
// `clear_first` is set — so planning after it meant the server answered
// "refused, nothing happened" having already wiped the customer's form
// field. The refusal now precedes every mutation on every branch,
// including the Vellum budget, which is decided in the same place.
let plan = if instant || humanized {
None
} else {
Some(plan_typing(text, wpm, MAX_BRIDGE_TYPING_SECONDS)?)
};
let inscribe_timeout = if humanized {
Some(vellum_typing_budget(text, MAX_BRIDGE_TYPING_SECONDS)?)
} else {
None
};
let target = resolve_target(&profile).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}}));
{}
}})()"#,
selector_escaped, selector_escaped, RETURN_RECT_JS
)
} else {
format!(
r#"(() => {{
const el = document.querySelector('{}');
if (!el) throw new Error('Element not found: {}');
el.scrollIntoView({{block: 'center'}});
el.focus();
{}
}})()"#,
selector_escaped, selector_escaped, RETURN_RECT_JS
)
};
let focus_result = self
.send_cdp(
&target,
"Runtime.evaluate",
serde_json::json!({
"expression": focus_js,
"returnByValue": true,
}),
)
.await?;
if let Some(exception) = focus_result.get("exceptionDetails") {
let msg = exception
.get("exception")
.and_then(|e| e.get("description"))
.or_else(|| exception.get("text"))
.and_then(|v| v.as_str())
.unwrap_or("Focus failed");
return Err(McpError {
code: -32000,
message: msg.to_string(),
data: None,
});
}
if let Some(timeout) = inscribe_timeout {
let point = rect_from_script_result(&focus_result)?;
let inscription = vellum_type(
&target,
point,
text,
typos,
caret_preparation(clear_first),
timeout,
)
.await
.map_err(AgentError::into_mcp)?;
return Ok(serde_json::json!({
"content": [{
"type": "text",
"text": typing_report(&format!("Typed text into element: {selector}"), &inscription)
}]
}));
}
match &plan {
Some(events) => self.send_planned_keystrokes(&target, events).await?,
None => {
self
.send_cdp(
&target,
"Input.insertText",
serde_json::json!({ "text": text }),
)
.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(),
data: None,
})?;
let format = arguments
.get("format")
.and_then(|v| v.as_str())
.unwrap_or("text");
let selector = arguments.get("selector").and_then(|v| v.as_str());
let max_chars = arguments
.get("max_chars")
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.unwrap_or(20_000);
let target = self.resolve_cdp_target(profile_id).await?;
let js = if let Some(sel) = selector {
let sel_escaped = sel.replace('\\', "\\\\").replace('\'', "\\'");
if format == "html" {
format!(
r#"(() => {{
const el = document.querySelector('{}');
return el ? el.outerHTML : null;
}})()"#,
sel_escaped
)
} else {
format!(
r#"(() => {{
const el = document.querySelector('{}');
return el ? el.innerText : null;
}})()"#,
sel_escaped
)
}
} else if format == "html" {
"document.documentElement.outerHTML".to_string()
} else {
"document.body.innerText".to_string()
};
let result = self
.send_cdp(
&target,
"Runtime.evaluate",
serde_json::json!({
"expression": js,
"returnByValue": true,
}),
)
.await?;
let content = result
.get("result")
.and_then(|r| r.get("value"))
.and_then(|v| v.as_str())
.unwrap_or("");
// Cap output so a 500 KB DOM dump doesn't blow out the agent's context.
// Slice on character boundaries (chars().take().collect()) rather than
// byte indices, since the latter would panic on multi-byte boundaries.
let total_chars = content.chars().count();
let (text, truncated) = if total_chars > max_chars {
(content.chars().take(max_chars).collect::<String>(), true)
} else {
(content.to_string(), false)
};
let payload = if truncated {
format!(
"{text}\n\n[truncated: showing {max_chars} of {total_chars} chars — call with a larger max_chars or use get_interactive_elements for an indexed view]"
)
} else {
text
};
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": payload
}]
}))
}
async fn handle_get_page_info(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
data: None,
})?;
let target = self.resolve_cdp_target(profile_id).await?;
let result = self
.send_cdp(
&target,
"Runtime.evaluate",
serde_json::json!({
"expression": "JSON.stringify({url: location.href, title: document.title, readyState: document.readyState})",
"returnByValue": true,
}),
)
.await?;
let info_str = result
.get("result")
.and_then(|r| r.get("value"))
.and_then(|v| v.as_str())
.unwrap_or("{}");
let info: serde_json::Value = serde_json::from_str(info_str).unwrap_or(serde_json::json!({}));
Self::json_content(&info)
}
async fn handle_get_interactive_elements(
&self,
caller: McpCaller<'_>,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
// FIRST, before the page is touched. The indices this hands back are only
// usable by a session, and a sessionless caller would otherwise write a
// snapshot into a slot it shares with every other sessionless caller, the
// exact array-mixing that makes click_by_index click the wrong element.
let session = require_indexed_session(caller)?;
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(),
data: None,
})?;
let max_chars = arguments
.get("max_chars")
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.unwrap_or(12_000);
let target = self.resolve_cdp_target(profile_id).await?;
// Walk the DOM for visible, non-disabled interactive elements, label them
// with a zero-based index, and cache the live references on THIS CALLER'S
// slot so click_by_index / type_by_index can resolve the index → Element
// without round-tripping a selector.
//
// Per session, because the slot used to be one shared `__donut_interactive`
// array on the page, and then one per transport. Either way a second client
// listing elements overwrote the array the first had just built, so that
// client's next `click_by_index(3)` clicked whatever happened to be third
// in the OTHER caller's snapshot, a wrong click reported as a successful
// one, which is the worst shape a failure can take on somebody's browser.
let slot = interactive_cache_slot(session);
let js = INTERACTIVE_ELEMENTS_JS
.replace("__MAX_CHARS__", &max_chars.to_string())
.replace("__CACHE__", &slot)
.replace("__REGISTRY__", INTERACTIVE_SLOT_REGISTRY)
.replace("__MAX_SLOTS__", &MAX_CACHE_SLOTS_PER_PAGE.to_string());
let result = self
.send_cdp(
&target,
"Runtime.evaluate",
serde_json::json!({
"expression": js,
"returnByValue": true,
}),
)
.await?;
if let Some(exception) = result.get("exceptionDetails") {
let msg = exception
.get("exception")
.and_then(|e| e.get("description"))
.or_else(|| exception.get("text"))
.and_then(|v| v.as_str())
.unwrap_or("Enumeration failed");
return Err(McpError {
code: -32000,
message: msg.to_string(),
data: None,
});
}
// Recorded only once the write has actually landed, so `end_session` cleans
// up pages this session really wrote to rather than every page it asked
// about.
self.remember_cached_page(session, profile_id, &slot).await;
let payload_str = result
.get("result")
.and_then(|r| r.get("value"))
.and_then(|v| v.as_str())
.unwrap_or("{}");
let payload: serde_json::Value =
serde_json::from_str(payload_str).unwrap_or(serde_json::json!({}));
let elements = payload
.get("elements")
.and_then(|v| v.as_str())
.unwrap_or("");
let count = payload.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
let truncated = payload
.get("truncated")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let header = if truncated {
format!("{count} interactive elements (truncated at {max_chars} chars — re-call with a larger max_chars or scroll the page):")
} else {
format!("{count} interactive elements:")
};
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!("{header}\n{elements}")
}]
}))
}
async fn handle_click_by_index(
&self,
caller: McpCaller<'_>,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
// Refused before anything else runs: an index names a position in an array
// a PREVIOUS call left on the page, and without a session there is no
// answer to whose array that is, only a shared slot to guess against.
let cache = interactive_cache_slot(require_indexed_session(caller)?);
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(),
data: None,
})?;
let index = self.element_index(profile_id, &cache, arguments).await?;
let ctx = self.agent_context(profile_id).await?;
if ctx.engine.is_wayfern() {
// As for click_element: the cached reference only says where the
// element is, and a real pointer does the clicking.
let point = locate_by_script(&ctx.target, indexed_rect_script(&cache, index)).await?;
let (_, navigated) = vellum_click(&ctx.target, point, None, None)
.await
.map_err(AgentError::into_mcp)?;
return Ok(
self
.with_page_line(
&ctx.target,
click_report(&format!("Clicked element at index {index}"), navigated),
)
.await,
);
}
let js = format!(
r#"(() => {{
const arr = window[{cache}];
if (!arr || !arr[{index}]) throw new Error('No element at index {index}. Call get_interactive_elements first or after navigation.');
const el = arr[{index}];
el.scrollIntoView({{block: 'center'}});
el.click();
return true;
}})()"#
);
let result = self
.send_cdp_and_wait_for_load(
&ctx.target,
"Runtime.evaluate",
serde_json::json!({
"expression": js,
"returnByValue": true,
}),
10,
false,
)
.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(),
data: None,
});
}
Ok(
self
.with_page_line(&ctx.target, format!("Clicked element at index {index}"))
.await,
)
}
async fn handle_type_by_index(
&self,
caller: McpCaller<'_>,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
// Same refusal as click_by_index, and for the same reason: typing into
// whatever happens to sit at index 3 of somebody else's snapshot is a
// wrong action reported as a successful one.
let cache = interactive_cache_slot(require_indexed_session(caller)?);
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(),
data: None,
})?;
let index = self.element_index(profile_id, &cache, arguments).await?;
let text = arguments
.get("text")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing text".to_string(),
data: None,
})?;
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 typos = arguments
.get("typos")
.and_then(|v| v.as_bool())
.unwrap_or(true);
// Engine first, then budgets, then the page: see handle_type_text.
let profile = self.get_wayfern_profile(profile_id)?;
let humanized = Engine::for_version(&profile.version).is_wayfern() && !instant;
// PLANNED FIRST, before anything touches the page. The planner can refuse
// (TYPING_TOO_LONG), and the focus step below empties the field when
// `clear_first` is set — so planning after it meant the server answered
// "refused, nothing happened" having already wiped the customer's form
// field. The refusal now precedes every mutation on every branch.
let plan = if instant || humanized {
None
} else {
Some(plan_typing(text, wpm, MAX_BRIDGE_TYPING_SECONDS)?)
};
let inscribe_timeout = if humanized {
Some(vellum_typing_budget(text, MAX_BRIDGE_TYPING_SECONDS)?)
} else {
None
};
let target = resolve_target(&profile).await?;
// Mirrors handle_type_text's focus step but resolves the element via the
// cached index instead of a CSS selector.
let focus_js = if clear_first {
format!(
r#"(() => {{
const arr = window[{cache}];
if (!arr || !arr[{index}]) throw new Error('No element at index {index}. Call get_interactive_elements first or after navigation.');
const el = arr[{index}];
el.scrollIntoView({{block: 'center'}});
el.focus();
el.value = '';
el.dispatchEvent(new Event('input', {{bubbles: true}}));
{RETURN_RECT_JS}
}})()"#
)
} else {
format!(
r#"(() => {{
const arr = window[{cache}];
if (!arr || !arr[{index}]) throw new Error('No element at index {index}. Call get_interactive_elements first or after navigation.');
const el = arr[{index}];
el.scrollIntoView({{block: 'center'}});
el.focus();
{RETURN_RECT_JS}
}})()"#
)
};
let focus_result = self
.send_cdp(
&target,
"Runtime.evaluate",
serde_json::json!({
"expression": focus_js,
"returnByValue": true,
}),
)
.await?;
if let Some(exception) = focus_result.get("exceptionDetails") {
let msg = exception
.get("exception")
.and_then(|e| e.get("description"))
.or_else(|| exception.get("text"))
.and_then(|v| v.as_str())
.unwrap_or("Focus failed");
return Err(McpError {
code: -32000,
message: msg.to_string(),
data: None,
});
}
if let Some(timeout) = inscribe_timeout {
let point = rect_from_script_result(&focus_result)?;
let inscription = vellum_type(
&target,
point,
text,
typos,
caret_preparation(clear_first),
timeout,
)
.await
.map_err(AgentError::into_mcp)?;
return Ok(serde_json::json!({
"content": [{
"type": "text",
"text": typing_report(&format!("Typed text into element at index {index}"), &inscription)
}]
}));
}
match &plan {
Some(events) => self.send_planned_keystrokes(&target, events).await?,
None => {
self
.send_cdp(
&target,
"Input.insertText",
serde_json::json!({ "text": text }),
)
.await?;
}
}
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!("Typed text into element at index {index}")
}]
}))
}
// --- Agent handlers: perception, locators, extraction, picker ---
/// The running browser behind `profile_id`, and the engine its version gets.
async fn agent_context(&self, profile_id: &str) -> Result<AgentContext, McpError> {
let profile = self.get_wayfern_profile(profile_id)?;
let target = resolve_target(&profile).await?;
Ok(AgentContext::new(profile, target))
}
/// Read a tool's arguments into the request type the shared operation takes.
///
/// `profile_id` and any other argument the type does not name are ignored,
/// which is what lets one struct serve both the MCP arguments and the REST
/// body.
fn agent_arguments<T: serde::de::DeserializeOwned>(
arguments: &serde_json::Value,
) -> Result<T, McpError> {
serde_json::from_value(arguments.clone()).map_err(|e| McpError {
code: -32602,
message: format!("Invalid arguments: {e}"),
data: None,
})
}
async fn handle_perceive_page(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = Self::require_str(arguments, "profile_id")?;
let request: PerceptionRequest = Self::agent_arguments(arguments)?;
let ctx = self.agent_context(profile_id).await?;
let page = agent_perceive(&ctx, &request)
.await
.map_err(AgentError::into_mcp)?;
Self::json_content(&page)
}
async fn handle_resolve_locator(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = Self::require_str(arguments, "profile_id")?;
let request: AgentResolveRequest = Self::agent_arguments(arguments)?;
let ctx = self.agent_context(profile_id).await?;
let resolved = agent_resolve_locator(&ctx, &request)
.await
.map_err(AgentError::into_mcp)?;
Self::json_content(&resolved)
}
async fn handle_click_locator(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = Self::require_str(arguments, "profile_id")?;
let request: AgentClickRequest = Self::agent_arguments(arguments)?;
let ctx = self.agent_context(profile_id).await?;
let clicked = agent_click_locator(&ctx, &request)
.await
.map_err(AgentError::into_mcp)?;
Self::json_content(&clicked)
}
async fn handle_type_locator(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = Self::require_str(arguments, "profile_id")?;
let request: AgentTypeRequest = Self::agent_arguments(arguments)?;
let ctx = self.agent_context(profile_id).await?;
let typed = agent_type_locator(&ctx, &request, MAX_BRIDGE_TYPING_SECONDS)
.await
.map_err(AgentError::into_mcp)?;
Self::json_content(&typed)
}
/// The element a form tool names: one of selector, locator, index or
/// element_id.
async fn form_element_ref(
&self,
caller: McpCaller<'_>,
profile_id: &str,
arguments: &serde_json::Value,
) -> Result<ElementRef, McpError> {
let given = |key: &str| arguments.get(key).is_some_and(|v| !v.is_null());
let named = ["selector", "locator", "index", "element_id"]
.into_iter()
.filter(|key| given(key))
.count();
if named != 1 {
return Err(McpError {
code: -32602,
message: "Name the element one way: selector, locator, index or element_id".to_string(),
data: None,
});
}
if given("locator") {
let locator: LocatorDescription = Self::agent_arguments(&arguments["locator"])?;
validate_locator(&locator).map_err(AgentError::into_mcp)?;
return Ok(ElementRef::Locator(canonical_locator(&locator)));
}
if given("selector") {
return Ok(ElementRef::Selector(
Self::require_str(arguments, "selector")?.to_string(),
));
}
let cache = interactive_cache_slot(require_indexed_session(caller)?);
let index = self.element_index(profile_id, &cache, arguments).await?;
Ok(ElementRef::Index { cache, index })
}
async fn handle_select_option(
&self,
caller: McpCaller<'_>,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = Self::require_str(arguments, "profile_id")?;
let wanted: Option<Vec<String>> = match (arguments.get("option"), arguments.get("options")) {
(Some(serde_json::Value::String(one)), None) => Some(vec![one.clone()]),
(None, Some(serde_json::Value::Array(many))) if !many.is_empty() => many
.iter()
.map(|v| v.as_str().map(str::to_string))
.collect(),
_ => None,
};
let wanted = wanted.ok_or_else(|| McpError {
code: -32602,
message: "Give option (one value or label) or options (a list of them)".to_string(),
data: None,
})?;
let element = self.form_element_ref(caller, profile_id, arguments).await?;
let ctx = self.agent_context(profile_id).await?;
let selected = agent_select_option(&ctx, &element, &wanted)
.await
.map_err(AgentError::into_mcp)?;
Self::json_content(&selected)
}
async fn handle_file_upload(
&self,
caller: McpCaller<'_>,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = Self::require_str(arguments, "profile_id")?;
let files = upload_files(arguments).map_err(AgentError::into_mcp)?;
let element = self.form_element_ref(caller, profile_id, arguments).await?;
let ctx = self.agent_context(profile_id).await?;
if ctx.target.is_remote() {
return Err(McpError {
code: -32000,
message: "file_upload works only for a profile whose browser runs on this computer."
.to_string(),
data: Some(serde_json::json!({ "code": "FILE_UPLOAD_LOCAL_ONLY" })),
});
}
// A browser that exited by itself left its files; nothing reads them now.
if let Ok(profiles) = ProfileManager::instance().list_profiles() {
let running: HashSet<String> = profiles
.iter()
.filter(|p| p.process_id.is_some())
.map(|p| p.id.to_string())
.collect();
crate::agent_uploads::sweep(|id| running.contains(id));
}
let uploaded = agent_file_upload(&ctx, &element, &files)
.await
.map_err(AgentError::into_mcp)?;
Self::json_content(&uploaded)
}
async fn handle_start_network_capture(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
use crate::network_capture::StartError;
let profile_id = Self::require_str(arguments, "profile_id")?;
let profile = self.get_wayfern_profile(profile_id)?;
match crate::network_capture::start(&profile).await {
Ok(answer) => Self::json_content(&answer),
Err(StartError::NotLocal) => Err(McpError {
code: -32000,
message: "No browser of this profile runs on this computer. Network capture works for a profile launched here with run_profile, not on the remote fleet.".to_string(),
data: Some(serde_json::json!({ "code": "NETWORK_CAPTURE_LOCAL_ONLY" })),
}),
Err(StartError::Refused(reason)) => Err(McpError {
code: -32000,
message: format!("The browser did not accept the capture: {reason}"),
data: Some(serde_json::json!({ "code": "NETWORK_CAPTURE_FAILED" })),
}),
}
}
fn handle_stop_network_capture(
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = Self::require_str(arguments, "profile_id")?;
let answer = match crate::network_capture::stop(profile_id) {
Some(requests) => serde_json::json!({ "stopped": true, "requests": requests }),
None => serde_json::json!({ "stopped": false }),
};
Self::json_content(&answer)
}
fn capture_not_running() -> McpError {
McpError {
code: -32000,
message: "No network capture runs for this profile. Call start_network_capture first; a capture ends when its browser stops.".to_string(),
data: Some(serde_json::json!({ "code": "NETWORK_CAPTURE_NOT_RUNNING" })),
}
}
fn handle_list_network_requests(
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
use crate::mcp_views::{DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT};
let profile_id = Self::require_str(arguments, "profile_id")?;
let text = |key: &str| {
arguments
.get(key)
.and_then(|v| v.as_str())
.filter(|v| !v.is_empty())
.map(str::to_string)
};
let limit = match arguments.get("limit") {
None | Some(serde_json::Value::Null) => DEFAULT_LIST_LIMIT,
Some(value) => value
.as_u64()
.filter(|n| (1..=MAX_LIST_LIMIT as u64).contains(n))
.ok_or_else(|| McpError {
code: -32602,
message: format!("limit is a whole number from 1 to {MAX_LIST_LIMIT}"),
data: None,
})? as usize,
};
let query = crate::network_capture::ListQuery {
after: arguments.get("after").and_then(|v| v.as_u64()).unwrap_or(0),
limit,
url_contains: text("url_contains"),
method: text("method"),
resource_type: text("type"),
status: arguments.get("status").and_then(|v| v.as_u64()),
failed: arguments.get("failed").and_then(|v| v.as_bool()),
body_contains: text("body_contains"),
};
let answer = crate::network_capture::read(profile_id, |log| log.list(&query))
.ok_or_else(Self::capture_not_running)?;
Self::json_content(&answer)
}
fn handle_get_network_request(
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = Self::require_str(arguments, "profile_id")?;
let id = arguments
.get("request_id")
.and_then(|v| v.as_u64())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing request_id".to_string(),
data: None,
})?;
let with_body = arguments
.get("body")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let offset = arguments
.get("body_offset")
.and_then(|v| v.as_u64())
.unwrap_or(0) as usize;
let max_chars = arguments
.get("max_body_chars")
.and_then(|v| v.as_u64())
.unwrap_or(DEFAULT_BODY_CHARS)
.clamp(1, MAX_BODY_CHARS) as usize;
match crate::network_capture::read(profile_id, |log| {
log.detail(id, with_body, offset, max_chars)
}) {
None => Err(Self::capture_not_running()),
Some(None) => Err(McpError {
code: -32000,
message: format!(
"No request {id} in the capture. Ids come from list_network_requests; the oldest are dropped past 2,000."
),
data: Some(serde_json::json!({ "code": "NETWORK_REQUEST_NOT_FOUND" })),
}),
Some(Some(answer)) => Self::json_content(&answer),
}
}
async fn handle_extract_structured(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = Self::require_str(arguments, "profile_id")?;
let mut request: ExtractionRequest = Self::agent_arguments(arguments)?;
// Clamped to what the transport can carry, like the picker's wait.
request.time_budget_ms = Some(
request
.time_budget_ms
.unwrap_or(8_000)
.min(MAX_BRIDGE_PICK_TIMEOUT_MS),
);
let ctx = self.agent_context(profile_id).await?;
let extraction = agent_extract(&ctx, &request)
.await
.map_err(AgentError::into_mcp)?;
Self::json_content(&extraction)
}
/// Reads and the human channel keep working while agents are paused.
fn is_passive_tool(tool_name: &str) -> bool {
crate::agent_console::HUMAN_TOOLS.contains(&tool_name)
|| tool_name == "send_feedback"
|| ((tool_name.starts_with("list_") || tool_name.starts_with("get_"))
&& !Self::is_automation_tool(tool_name))
}
fn error_code_of(error: &McpError) -> String {
if let Some(code) = error
.data
.as_ref()
.and_then(|data| data.get("code"))
.and_then(serde_json::Value::as_str)
{
return code.to_string();
}
if let Some(code) = serde_json::from_str::<serde_json::Value>(&error.message)
.ok()
.and_then(|value| {
value
.get("code")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
})
{
return code;
}
match error.code {
-32602 => "INVALID_ARGUMENT".to_string(),
_ => "TOOL_ERROR".to_string(),
}
}
fn console_refusal(refusal: crate::agent_console::Refusal) -> McpError {
match refusal {
crate::agent_console::Refusal::Paused { note } => McpError {
code: -32000,
message: match &note {
Some(note) => format!("The person at this computer paused all agents: {note}. Wait, then call get_human_updates before you continue."),
None => "The person at this computer paused all agents. Wait, then call get_human_updates before you continue.".to_string(),
},
data: Some(serde_json::json!({ "code": "AGENT_PAUSED", "note": note })),
},
crate::agent_console::Refusal::ProfileHeld { profile_id, note } => McpError {
code: -32000,
message: match &note {
Some(note) => format!("The person at this computer has taken over profile {profile_id}: {note}. Work on other profiles; get_human_updates shows when it is handed back."),
None => format!("The person at this computer has taken over profile {profile_id}. Work on other profiles; get_human_updates shows when it is handed back."),
},
data: Some(serde_json::json!({ "code": "PROFILE_HELD_BY_HUMAN", "profileId": profile_id, "note": note })),
},
}
}
fn attach_notes(value: &mut serde_json::Value, session: Option<&str>) {
let Some(content) = value
.get_mut("content")
.and_then(serde_json::Value::as_array_mut)
else {
return;
};
for note in crate::agent_console::take_notes(session) {
content.push(serde_json::json!({
"type": "text",
"text": crate::agent_console::note_text(&note),
}));
}
}
fn wait_seconds(arguments: &serde_json::Value) -> u64 {
arguments
.get("wait_seconds")
.and_then(serde_json::Value::as_u64)
.unwrap_or(crate::agent_console::DEFAULT_WAIT_SECS)
.min(crate::agent_console::MAX_WAIT_SECS)
}
fn invalid_argument(message: impl Into<String>) -> McpError {
McpError {
code: -32602,
message: message.into(),
data: Some(serde_json::json!({ "code": "INVALID_ARGUMENT" })),
}
}
fn optional_profile(&self, arguments: &serde_json::Value) -> Result<Option<String>, McpError> {
let Some(profile_id) = arguments
.get("profile_id")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|id| !id.is_empty())
else {
return Ok(None);
};
let known = crate::profile::manager::ProfileManager::instance()
.list_profiles()
.map(|profiles| profiles.iter().any(|p| p.id.to_string() == profile_id))
.unwrap_or(false);
if !known {
return Err(Self::invalid_argument(format!(
"Unknown profile_id {profile_id}"
)));
}
Ok(Some(profile_id.to_string()))
}
async fn handle_ask_human(
&self,
caller: McpCaller<'_>,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let question = arguments
.get("question")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let choices: Vec<String> = arguments
.get("choices")
.and_then(serde_json::Value::as_array)
.map(|list| {
list
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let (question, choices) = crate::agent_console::validate_question(question, &choices)
.map_err(Self::invalid_argument)?;
let profile_id = self.optional_profile(arguments)?;
let request_id = crate::agent_console::open_request(crate::agent_console::NewRequest {
session_id: caller.session.map(str::to_string),
kind: crate::agent_console::ThreadKind::Question,
text: question,
profile_id,
choices,
})
.await;
let outcome = crate::agent_console::wait_for(request_id, Self::wait_seconds(arguments))
.await
.unwrap_or(crate::agent_console::RequestOutcome::Pending);
Self::json_content(&crate::agent_console::outcome_json(request_id, &outcome))
}
async fn handle_request_human_help(
&self,
caller: McpCaller<'_>,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = self
.optional_profile(arguments)?
.ok_or_else(|| Self::invalid_argument("profile_id is required"))?;
let reason = arguments
.get("reason")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let (reason, _) = crate::agent_console::validate_question(reason, &[]).map_err(|_| {
Self::invalid_argument(format!(
"reason must be 1 to {} characters",
crate::agent_console::MAX_TEXT_CHARS
))
})?;
let request_id = crate::agent_console::open_request(crate::agent_console::NewRequest {
session_id: caller.session.map(str::to_string),
kind: crate::agent_console::ThreadKind::Help,
text: reason,
profile_id: Some(profile_id),
choices: Vec::new(),
})
.await;
let outcome = crate::agent_console::wait_for(request_id, Self::wait_seconds(arguments))
.await
.unwrap_or(crate::agent_console::RequestOutcome::Pending);
Self::json_content(&crate::agent_console::outcome_json(request_id, &outcome))
}
async fn handle_wait_for_human(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let request_id = arguments
.get("request_id")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| Self::invalid_argument("request_id is required"))?;
if !crate::agent_console::request_exists(request_id) {
return Err(McpError {
code: -32602,
message: format!("No request {request_id}; it may be from before Donut restarted"),
data: Some(serde_json::json!({ "code": "AGENT_REQUEST_NOT_FOUND" })),
});
}
let outcome = crate::agent_console::wait_for(request_id, Self::wait_seconds(arguments))
.await
.unwrap_or(crate::agent_console::RequestOutcome::Pending);
Self::json_content(&crate::agent_console::outcome_json(request_id, &outcome))
}
fn handle_report_progress(
caller: McpCaller<'_>,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let message = arguments
.get("message")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|message| !message.is_empty())
.ok_or_else(|| Self::invalid_argument("message is required"))?;
let done = arguments.get("done").and_then(serde_json::Value::as_u64);
let total = arguments.get("total").and_then(serde_json::Value::as_u64);
let profile_id = arguments
.get("profile_id")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|id| !id.is_empty())
.map(str::to_string);
crate::agent_console::report_progress(caller.session, message, done, total, profile_id);
Self::json_content(&serde_json::json!({ "ok": true }))
}
async fn handle_send_feedback(
&self,
caller: McpCaller<'_>,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
use crate::feedback::{FeedbackKind, FeedbackSource, MAX_MESSAGE_CHARS};
let message = arguments
.get("message")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|message| !message.is_empty())
.ok_or_else(|| Self::invalid_argument("message is required"))?;
if message.chars().count() > MAX_MESSAGE_CHARS {
return Err(Self::invalid_argument(format!(
"message is longer than {MAX_MESSAGE_CHARS} characters"
)));
}
let kind = match arguments.get("kind") {
None | Some(serde_json::Value::Null) => FeedbackKind::Other,
Some(value) => value
.as_str()
.and_then(FeedbackKind::parse)
.ok_or_else(|| Self::invalid_argument("kind must be bug, idea, praise or other"))?,
};
let include_logs = arguments
.get("include_logs")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
if !crate::feedback::agent_send_allowed() {
return Err(McpError {
code: -32000,
message: "Too much feedback was sent in the last hour. Try again later.".to_string(),
data: Some(serde_json::json!({ "code": "FEEDBACK_RATE_LIMITED" })),
});
}
let logs = if include_logs {
let app_handle = self.inner.lock().await.app_handle.clone();
match app_handle {
Some(handle) => Some(
crate::feedback::log_excerpt(crate::app_dirs::log_dir(&handle))
.await
.map_err(|_| McpError {
code: -32000,
message: "The app logs could not be read. Send the feedback without include_logs."
.to_string(),
data: Some(serde_json::json!({ "code": "FEEDBACK_LOGS_UNREADABLE" })),
})?,
),
None => None,
}
} else {
None
};
let logs_attached = logs.as_ref().is_some_and(|logs| !logs.is_empty());
let (agent_client, recent_calls) = crate::agent_console::feedback_context(caller.session);
let receipt = crate::feedback::send(crate::feedback::Feedback {
kind,
source: FeedbackSource::Agent,
message: message.to_string(),
email: None,
locale: None,
logs,
agent_client,
context: (!recent_calls.is_empty())
.then(|| serde_json::json!({ "recentCalls": recent_calls })),
})
.await
.map_err(|error| {
let code = serde_json::from_str::<serde_json::Value>(&error)
.ok()
.and_then(|value| {
value
.get("code")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
})
.unwrap_or_else(|| "FEEDBACK_SEND_FAILED".to_string());
let message = match code.as_str() {
"FEEDBACK_RATE_LIMITED" => {
"The Donut team's server refused more feedback for now. Try again later."
}
"CLOUD_UNREACHABLE" | "SYSTEM_PROXY_UNREACHABLE" => {
"The Donut team's server could not be reached. Try again later."
}
_ => "The feedback could not be sent. Try again later.",
};
McpError {
code: -32000,
message: message.to_string(),
data: Some(serde_json::json!({ "code": code })),
}
})?;
crate::agent_console::feedback_sent(caller.session, kind.as_str(), message, logs_attached);
Self::json_content(&serde_json::json!({
"sent": true,
"feedback_id": receipt.id,
"logs_attached": logs_attached,
}))
}
pub(crate) async fn bring_profile_to_front(&self, profile_id: &str) -> Result<(), String> {
let target = self
.resolve_cdp_target(profile_id)
.await
.map_err(|_| crate::backend_error("PROFILE_NOT_RUNNING"))?;
self
.send_cdp(&target, "Page.bringToFront", serde_json::json!({}))
.await
.map(|_| ())
.map_err(|e| {
log::warn!(
"MCP profile not brought to front profile={profile_id} err=\"{}\"",
e.message
);
crate::backend_error("PROFILE_NOT_RUNNING")
})
}
async fn handle_pick_element(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = Self::require_str(arguments, "profile_id")?;
let request: AgentPickRequest = Self::agent_arguments(arguments)?;
// Clamped rather than refused: a caller asking for ten minutes over the
// bridge gets the longest wait the relay will actually carry.
let timeout_ms = request
.timeout_ms
.unwrap_or(DEFAULT_PICK_TIMEOUT_MS)
.clamp(1_000, MAX_BRIDGE_PICK_TIMEOUT_MS);
let ctx = self.agent_context(profile_id).await?;
let picked = agent_pick_element(&ctx, timeout_ms)
.await
.map_err(AgentError::into_mcp)?;
Self::json_content(&picked)
}
// --- 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(),
data: None,
})?;
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(),
data: None,
})?
.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(),
data: None,
})?
};
let info = crate::synchronizer::SynchronizerManager::instance()
.start_session(app, leader_id.to_string(), follower_ids)
.await
.map_err(|e| McpError {
code: -32000,
message: e,
data: None,
})?;
Self::json_content(&info)
}
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(),
data: None,
})?;
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(),
data: None,
})?
};
crate::synchronizer::SynchronizerManager::instance()
.stop_session(app, session_id)
.await
.map_err(|e| McpError {
code: -32000,
message: e,
data: None,
})?;
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;
Self::json_content(&sessions)
}
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(),
data: None,
})?;
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(),
data: None,
})?;
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(),
data: None,
})?
};
crate::synchronizer::SynchronizerManager::instance()
.remove_follower(app, session_id, follower_id)
.await
.map_err(|e| McpError {
code: -32000,
message: e,
data: None,
})?;
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": "Follower removed from sync session"
}]
}))
}
// --- Remote fleet and cookie bot -----------------------------------------
//
// Every tool below is a proxy onto Donut cloud, which owns the schedule, the
// calendar arithmetic, the browsing behaviour and the pooled hour budget.
// Nothing here decides when a run happens or what it does. What this file
// DOES decide is which profiles may be offered to the bot at all.
/// Render a value as a tool's answer (see `mcp_views::tool_json`).
fn json_content<T: Serialize>(value: &T) -> Result<serde_json::Value, McpError> {
let value = serde_json::to_value(value).map_err(|e| McpError {
code: -32000,
message: format!("Failed to encode response: {e}"),
data: None,
})?;
Ok(crate::mcp_views::tool_json(value))
}
/// The names an answer shows next to the ids a profile points at.
fn profile_names() -> crate::mcp_views::Names {
let groups = GROUP_MANAGER
.lock()
.ok()
.and_then(|manager| manager.get_all_groups().ok())
.unwrap_or_default()
.into_iter()
.map(|group| (group.id, group.name))
.collect();
let proxies = PROXY_MANAGER
.get_stored_proxies()
.into_iter()
.map(|proxy| (proxy.id, proxy.name))
.collect();
let vpns = crate::vpn::VPN_STORAGE
.lock()
.ok()
.and_then(|storage| storage.list_configs().ok())
.unwrap_or_default()
.into_iter()
.map(|vpn| (vpn.id, vpn.name))
.collect();
let statuses = crate::profile_statuses::list()
.into_iter()
.map(|status| (status.id, status.name))
.collect();
crate::mcp_views::Names {
groups,
proxies,
vpns,
statuses,
remote: crate::remote_session::indexed_live_profiles(),
}
}
/// Read a caller-supplied URL and validate its scheme in one step.
///
/// The guard belongs HERE rather than at each call site: a regression test
/// that scans for `get("url")` cannot see a handler that reads the same
/// argument through `require_str`, and a handler that reads a URL without
/// validating it is how `file:///…` plus a content tool became a remote file
/// read. Reaching for this instead of `require_str` makes the safe path the
/// short one.
fn require_navigable_url<'a>(
arguments: &'a serde_json::Value,
key: &str,
) -> Result<&'a str, McpError> {
let url = Self::require_str(arguments, key)?;
validate_navigable_url(url)?;
Ok(url)
}
fn require_str<'a>(arguments: &'a serde_json::Value, key: &str) -> Result<&'a str, McpError> {
arguments
.get(key)
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.ok_or_else(|| McpError {
code: -32602,
message: format!("Missing {key}"),
data: None,
})
}
/// Read a whole-number argument, refusing anything that would silently wrap.
///
/// `as_u64() as u16` would turn a run time of 1440 into 1440 but 65536 into
/// 0, quietly scheduling a run at midnight nobody asked for.
fn require_u16(arguments: &serde_json::Value, key: &str) -> Result<u16, McpError> {
Self::optional_u16(arguments, key)?.ok_or_else(|| McpError {
code: -32602,
message: format!("Missing {key}"),
data: None,
})
}
fn optional_u16(arguments: &serde_json::Value, key: &str) -> Result<Option<u16>, McpError> {
let Some(value) = arguments.get(key).filter(|value| !value.is_null()) else {
return Ok(None);
};
value
.as_u64()
.and_then(|raw| u16::try_from(raw).ok())
.map(Some)
.ok_or_else(|| McpError {
code: -32602,
message: format!("{key} must be a whole number between 0 and 65535"),
data: None,
})
}
fn optional_u8(arguments: &serde_json::Value, key: &str) -> Result<Option<u8>, McpError> {
let Some(value) = arguments.get(key).filter(|value| !value.is_null()) else {
return Ok(None);
};
value
.as_u64()
.and_then(|raw| u8::try_from(raw).ok())
.map(Some)
.ok_or_else(|| McpError {
code: -32602,
message: format!("{key} must be a whole number between 0 and 255"),
data: None,
})
}
fn optional_u32(arguments: &serde_json::Value, key: &str) -> Result<Option<u32>, McpError> {
let Some(value) = arguments.get(key).filter(|value| !value.is_null()) else {
return Ok(None);
};
value
.as_u64()
.and_then(|raw| u32::try_from(raw).ok())
.map(Some)
.ok_or_else(|| McpError {
code: -32602,
message: format!("{key} must be a whole number between 0 and 4294967295"),
data: None,
})
}
/// A cloud failure, rendered as the `{"code":…,"params":{…}}` envelope.
///
/// The backend's own English would be meaningless to an agent deciding what
/// to do next; a stable code and its parameters are something it can branch
/// on, and it is the same envelope the desktop and the REST API answer with.
fn cloud_error(err: crate::cookie_bot::CookieBotError) -> McpError {
McpError {
code: -32000,
message: err.to_error_json(),
data: None,
}
}
/// Resolve a profile the cookie bot is allowed to touch.
///
/// The same gate the REST surface applies, for the same reason: the bot runs
/// ONLY on the leased fleet, so a profile that cannot make the round trip to
/// a remote host and back — never synced, encrypted with a key that never
/// leaves this machine, no recorded OS, an OS the fleet cannot lease, or no
/// proxy or VPN to egress through — must never reach an enrolment, a quota
/// check or a leased host on ANY surface.
fn cookie_bot_eligible_profile(profile_id: &str) -> Result<BrowserProfile, McpError> {
let profiles = ProfileManager::instance()
.list_profiles()
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to list profiles: {e}"),
data: None,
})?;
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}"),
data: None,
})?;
crate::cookie_bot::run_precondition(&profile).map_err(|message| McpError {
code: -32000,
message,
data: None,
})?;
Ok(profile)
}
/// Start this profile on the remote host its platform names.
///
/// Deliberately no `is_cross_os` guard: local `run_profile` refuses a profile
/// bound to another OS because THIS machine is the wrong OS, and running it
/// on a host of its own OS is precisely what this exists for.
async fn handle_run_profile_remote(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = Self::require_str(arguments, "profile_id")?;
let url = arguments
.get("url")
.and_then(|v| v.as_str())
.map(str::to_string);
if let Some(url) = url.as_deref() {
validate_navigable_url(url)?;
}
let profile = self.get_wayfern_profile(profile_id)?;
// The host pulls the profile from cloud storage, so one that has never
// synced would launch an empty browser and push that emptiness back over
// the real one. Same rule the REST route applies, from the same place.
crate::api_server::remote_launch_precondition(&profile)
.await
.map_err(|message| McpError {
code: -32000,
message,
data: None,
})?;
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(),
data: None,
})?
};
let outcome = crate::remote_session::start_remote_session(app, &profile, url)
.await
.map_err(|e| McpError {
code: -32000,
message: e.to_error_json(),
data: None,
})?;
Self::json_content(&outcome)
}
/// Stop a remote session and settle what it cost.
async fn handle_stop_remote_session(
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let session_id = Self::require_str(arguments, "session_id")?;
let outcome = crate::remote_session::end_remote_session(session_id)
.await
.map_err(|e| McpError {
code: -32000,
message: e.to_error_json(),
data: None,
})?;
Self::json_content(&outcome)
}
async fn handle_list_remote_sessions() -> Result<serde_json::Value, McpError> {
let sessions = crate::remote_session::list_remote_sessions()
.await
.map_err(|e| McpError {
code: -32000,
message: e.to_error_json(),
data: None,
})?;
Self::json_content(&sessions)
}
async fn handle_get_remote_session(
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let session_id = Self::require_str(arguments, "session_id")?;
let state = crate::remote_session::get_remote_session(session_id)
.await
.map_err(|e| McpError {
code: -32000,
message: e.to_error_json(),
data: None,
})?;
Self::json_content(&state)
}
async fn handle_get_remote_hours_quota() -> Result<serde_json::Value, McpError> {
let quota = crate::cookie_bot::remote_hours_quota()
.await
.map_err(Self::cloud_error)?;
Self::json_content(&quota)
}
async fn handle_list_cookie_bot_schedules(
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let scope = arguments.get("scope").and_then(|value| value.as_str());
let schedules = crate::cookie_bot::list_schedules(scope)
.await
.map_err(Self::cloud_error)?;
Self::json_content(&schedules)
}
async fn handle_get_cookie_bot_schedule(
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = Self::require_str(arguments, "profile_id")?;
// Not gated on eligibility: a profile whose sync was turned off after it
// was enrolled must still be able to show what it is enrolled as.
let schedule = crate::cookie_bot::get_schedule(profile_id)
.await
.map_err(Self::cloud_error)?;
Self::json_content(&schedule)
}
async fn handle_set_cookie_bot_schedule(
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = Self::require_str(arguments, "profile_id")?;
let profile = Self::cookie_bot_eligible_profile(profile_id)?;
// `bot_precondition` already proved the profile has an OS the fleet can
// lease. Taking the platform from the profile rather than the arguments is
// what stops an agent enrolling a macOS profile onto a Windows host; a
// Wayfern profile runs on a Linux host whatever OS it claims.
let platform = profile
.remote_platform()
.ok_or_else(|| McpError {
code: -32000,
message: "Profile has no recorded operating system".to_string(),
data: None,
})?
.to_string();
if let Some(requested) = arguments.get("platform").and_then(|v| v.as_str()) {
if requested != platform {
return Err(McpError {
code: -32602,
message: format!(
"platform {requested:?} does not match the platform this profile runs on remotely {platform:?}"
),
data: None,
});
}
}
let enabled = arguments
.get("enabled")
.and_then(|value| value.as_bool())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing enabled".to_string(),
data: None,
})?;
let sites = arguments
.get("sites")
.and_then(|value| value.as_array())
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str().map(str::to_string))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let input = crate::cookie_bot::CookieBotScheduleInput {
profile_name: arguments
.get("profile_name")
.and_then(|value| value.as_str())
.map_or_else(|| profile.name.clone(), str::to_string),
platform,
enabled,
run_at_minute: Self::require_u16(arguments, "run_at_minute")?,
days_mask: Self::optional_u8(arguments, "days_mask")?.ok_or_else(|| McpError {
code: -32602,
message: "Missing days_mask".to_string(),
data: None,
})?,
timezone: Self::require_str(arguments, "timezone")?.to_string(),
preset: Self::require_str(arguments, "preset")?.to_string(),
max_minutes: Self::optional_u32(arguments, "max_minutes")?.ok_or_else(|| McpError {
code: -32602,
message: "Missing max_minutes".to_string(),
data: None,
})?,
sites,
jitter_seconds: Self::optional_u32(arguments, "jitter_seconds")?,
..Default::default()
}
// Derived from the profile, never from the tool arguments: an agent must not
// be able to claim a profile has a proxy when it does not.
.with_profile_state(crate::cookie_bot::profile_state(&profile));
let acknowledge_conflict = arguments
.get("acknowledge_conflict")
.and_then(|value| value.as_bool())
.unwrap_or(false);
let saved = crate::cookie_bot::save_schedule(profile_id, &input, acknowledge_conflict)
.await
.map_err(Self::cloud_error)?;
Self::json_content(&saved)
}
async fn handle_delete_cookie_bot_schedule(
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = Self::require_str(arguments, "profile_id")?;
// No eligibility gate: a profile that has since become ineligible is
// exactly the one an agent most needs to be able to unenrol.
let deleted = crate::cookie_bot::delete_schedule(profile_id)
.await
.map_err(Self::cloud_error)?;
Self::json_content(&deleted)
}
async fn handle_check_cookie_bot_conflicts(
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = Self::require_str(arguments, "profile_id")?;
let conflicts = crate::cookie_bot::check_conflicts(
profile_id,
Self::optional_u16(arguments, "run_at_minute")?,
arguments.get("timezone").and_then(|value| value.as_str()),
Self::optional_u8(arguments, "days_mask")?,
)
.await
.map_err(Self::cloud_error)?;
Self::json_content(&conflicts)
}
async fn handle_list_cookie_bot_runs(
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let runs = crate::cookie_bot::list_runs(
arguments.get("profile_id").and_then(|value| value.as_str()),
arguments.get("scope").and_then(|value| value.as_str()),
Self::optional_u32(arguments, "limit")?,
arguments.get("before").and_then(|value| value.as_str()),
)
.await
.map_err(Self::cloud_error)?;
Self::json_content(&runs)
}
async fn handle_run_cookie_bot_now(
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = Self::require_str(arguments, "profile_id")?;
Self::cookie_bot_eligible_profile(profile_id)?;
let started =
crate::cookie_bot::run_now(profile_id, Self::optional_u32(arguments, "max_minutes")?)
.await
.map_err(Self::cloud_error)?;
Self::json_content(&started)
}
async fn handle_cancel_cookie_bot_run(
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let run_id = Self::require_str(arguments, "run_id")?;
let run = crate::cookie_bot::cancel_run(run_id)
.await
.map_err(Self::cloud_error)?;
Self::json_content(&run)
}
async fn handle_list_cookie_bot_presets() -> Result<serde_json::Value, McpError> {
// Ids and a rough duration only. What a preset expands to is the server's,
// and stays there.
let presets = crate::cookie_bot::list_presets()
.await
.map_err(Self::cloud_error)?;
Self::json_content(&presets)
}
async fn handle_get_cookie_bot_usage(
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let usage = crate::cookie_bot::team_usage(arguments.get("period").and_then(|v| v.as_str()))
.await
.map_err(Self::cloud_error)?;
Self::json_content(&usage)
}
}
static MCP_SERVER: std::sync::LazyLock<McpServer> = std::sync::LazyLock::new(McpServer::new);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_engine_plan_refusal_is_explained_by_a_device_refusal() {
let engine = || {
McpError {
code: -32000,
message: r#"CDP error: {"code":-32000,"message":"Browser automation requires a paid Donut Browser plan."}"#
.to_string(),
data: None,
}
};
let device = "Browser automation is restricted to your primary device. Log out other devices to use it here.";
assert_eq!(
explain_engine_refusal(engine(), Some(device.to_string())).message,
device
);
assert_eq!(
explain_engine_refusal(engine(), None).message,
engine().message
);
let other = McpError {
code: -32000,
message: "Profile not found".to_string(),
data: None,
};
assert_eq!(
explain_engine_refusal(other, Some(device.to_string())).message,
"Profile not found"
);
}
#[test]
fn a_bot_check_is_named_but_a_login_form_is_not() {
assert_eq!(bot_wall("Just a moment...", ""), Some("a Cloudflare check"));
assert_eq!(
bot_wall("Example", "Please verify you are human to continue"),
Some("a human check")
);
assert_eq!(
bot_wall(
"Sign in",
"This site is protected by reCAPTCHA and the Google Privacy Policy"
),
None,
"a CAPTCHA mentioned in a form's small print is not a wall"
);
let report = page_report(&PageState {
url: "https://shop.example/".into(),
title: "Attention Required! | Cloudflare".into(),
text: String::new(),
});
assert!(report.starts_with("Page: Attention Required! | Cloudflare <https://shop.example/>"));
assert!(report.contains("a Cloudflare block"));
assert_eq!(
page_report(&PageState {
url: "about:blank".into(),
..Default::default()
}),
"Page: (no title) <about:blank>"
);
}
#[test]
fn a_screenshot_is_scaled_down_to_the_width_asked_and_never_up() {
assert_eq!(screenshot_scale(1280.0, 1.0, 1280.0), 1.0);
assert_eq!(screenshot_scale(1280.0, 2.0, 1280.0), 0.5);
assert_eq!(screenshot_scale(800.0, 1.0, 1280.0), 1.0);
assert_eq!(screenshot_scale(0.0, 1.0, 1280.0), 1.0);
let metrics = LayoutMetrics::from_reply(&serde_json::json!({
"cssVisualViewport": { "pageX": 0, "pageY": 600, "clientWidth": 1280, "clientHeight": 720 },
"visualViewport": { "clientWidth": 2560 },
"cssContentSize": { "width": 1280, "height": 5000 },
}));
assert_eq!(metrics.y, 600.0);
assert_eq!(metrics.dpr, 2.0);
assert_eq!(metrics.page_height, 5000.0);
// An older browser without the CSS metrics still gets a sane capture.
let bare = LayoutMetrics::from_reply(&serde_json::json!({}));
assert_eq!((bare.width, bare.dpr), (1280.0, 1.0));
}
#[test]
fn scrolling_moves_most_of_a_viewport_per_page() {
assert_eq!(scroll_delta("down", 1.0, 1000.0, 800.0), Some((0.0, 680.0)));
assert_eq!(scroll_delta("up", 2.0, 1000.0, 800.0), Some((0.0, -1360.0)));
assert_eq!(
scroll_delta("left", 1.0, 1000.0, 800.0),
Some((-850.0, 0.0))
);
assert_eq!(scroll_delta("sideways", 1.0, 1000.0, 800.0), None);
}
#[test]
fn a_key_name_becomes_the_event_a_keyboard_sends() {
let enter = key_stroke("Enter").unwrap();
assert_eq!((enter.key_code, enter.text.as_deref()), (13, Some("\r")));
let a = key_stroke("a").unwrap();
assert_eq!((a.code.as_str(), a.key_code), ("KeyA", 65));
assert_eq!(key_stroke("7").unwrap().code, "Digit7");
assert_eq!(key_stroke("Space").unwrap().text.as_deref(), Some(" "));
assert!(key_stroke("Enterr").is_none());
assert!(key_stroke("").is_none());
assert_eq!(modifier_bits(&["ctrl", "shift"]), Ok(10));
assert!(modifier_bits(&["hyper"]).is_err());
}
#[test]
fn wait_for_quotes_what_it_looks_for() {
let script = wait_for_script(Some("It's \"done\""), None).unwrap();
assert!(script.contains(r#".includes("It's \"done\"")"#), "{script}");
let script = wait_for_script(None, Some("#a[b='c']")).unwrap();
assert!(
script.contains(r##"querySelector("#a[b='c']")"##),
"{script}"
);
assert!(wait_for_script(None, None).is_none());
assert!(wait_for_script(Some("x"), Some("#y")).is_none());
}
/// Whether a scanned method body is the tool listing rather than a handler.
fn is_tool_listing(body: &str) -> bool {
body.trim_start().starts_with("pub fn get_tools")
}
#[test]
fn every_batchable_tool_exists_and_none_reads_files() {
let names: Vec<String> = McpServer::new()
.get_tools()
.into_iter()
.map(|t| t.name)
.collect();
for tool in BATCH_TOOLS {
assert!(names.iter().any(|name| name == tool), "{tool}");
assert!(!LOCAL_PATH_TOOLS.contains(tool), "{tool}");
}
assert!(!BATCH_TOOLS.contains(&"run_actions"), "a batch never nests");
}
#[test]
fn test_mcp_tools_count() {
let server = McpServer::new();
let tools = server.get_tools();
// PINNED, not a floor. This read `>= 59` while the server actually served
// 80, so twenty-one tools could be deleted without the assertion moving -
// and every one of them is a published contract an MCP client is written
// against. A floor that sits far below the real number is not a test, it
// is a comment. Changing this number is the deliberate edit that says a
// tool was added or removed on purpose.
assert_eq!(
tools.len(),
113,
"the tool list is a published contract; update this number deliberately"
);
// Names are the contract an MCP client is written against, so a duplicate
// silently shadows one of the two in dispatch and the tool that loses is
// simply never reachable.
let mut seen = std::collections::HashSet::new();
for tool in &tools {
assert!(
seen.insert(tool.name.as_str()),
"duplicate MCP tool name: {}",
tool.name
);
}
// Check tool names
let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
// Profile tools
assert!(tool_names.contains(&"list_profiles"));
assert!(tool_names.contains(&"get_profile"));
assert!(tool_names.contains(&"run_profile"));
assert!(tool_names.contains(&"kill_profile"));
assert!(tool_names.contains(&"get_profile_status"));
// Profile import tools
assert!(tool_names.contains(&"detect_browser_profiles"));
assert!(tool_names.contains(&"import_browser_profiles"));
// Group tools
assert!(tool_names.contains(&"list_groups"));
assert!(tool_names.contains(&"get_group"));
assert!(tool_names.contains(&"create_group"));
assert!(tool_names.contains(&"update_group"));
assert!(tool_names.contains(&"delete_group"));
assert!(tool_names.contains(&"assign_profiles_to_group"));
// Proxy tools
assert!(tool_names.contains(&"distribute_proxies"));
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(&"add_extension"));
assert!(tool_names.contains(&"update_extension"));
assert!(tool_names.contains(&"create_extension_group"));
assert!(tool_names.contains(&"update_extension_group"));
assert!(tool_names.contains(&"add_extension_to_group"));
assert!(tool_names.contains(&"remove_extension_from_group"));
assert!(tool_names.contains(&"delete_extension"));
assert!(tool_names.contains(&"delete_extension_group"));
assert!(tool_names.contains(&"assign_extension_group_to_profile"));
// Cookie tools
assert!(tool_names.contains(&"import_profile_cookies"));
// Team lock tools
assert!(tool_names.contains(&"get_team_locks"));
assert!(tool_names.contains(&"get_team_lock_status"));
// Synchronizer tools
assert!(tool_names.contains(&"start_sync_session"));
assert!(tool_names.contains(&"stop_sync_session"));
assert!(tool_names.contains(&"get_sync_sessions"));
assert!(tool_names.contains(&"remove_sync_follower"));
// Browser interaction tools
assert!(tool_names.contains(&"navigate"));
assert!(tool_names.contains(&"screenshot"));
assert!(tool_names.contains(&"evaluate_javascript"));
assert!(tool_names.contains(&"click_element"));
assert!(tool_names.contains(&"type_text"));
assert!(tool_names.contains(&"get_page_content"));
assert!(tool_names.contains(&"get_page_info"));
// The agent surface: what an agent reads, how it names things, and how it
// acts on them without a selector.
assert!(tool_names.contains(&"perceive_page"));
assert!(tool_names.contains(&"resolve_locator"));
assert!(tool_names.contains(&"click_locator"));
assert!(tool_names.contains(&"type_locator"));
assert!(tool_names.contains(&"select_option"));
assert!(tool_names.contains(&"file_upload"));
// Network capture tools
assert!(tool_names.contains(&"start_network_capture"));
assert!(tool_names.contains(&"stop_network_capture"));
assert!(tool_names.contains(&"list_network_requests"));
assert!(tool_names.contains(&"get_network_request"));
assert!(tool_names.contains(&"extract_structured"));
assert!(tool_names.contains(&"pick_element"));
// Remote fleet: an agent must be able to start a session, see it become
// usable, drive it with the tools above, and stop it. Any one of those
// missing makes remote driving unusable from MCP alone.
assert!(tool_names.contains(&"run_profile_remote"));
assert!(tool_names.contains(&"stop_remote_session"));
assert!(tool_names.contains(&"list_remote_sessions"));
assert!(tool_names.contains(&"get_remote_session"));
assert!(tool_names.contains(&"get_remote_hours_quota"));
// Cookie bot
assert!(tool_names.contains(&"list_cookie_bot_schedules"));
assert!(tool_names.contains(&"get_cookie_bot_schedule"));
assert!(tool_names.contains(&"set_cookie_bot_schedule"));
assert!(tool_names.contains(&"delete_cookie_bot_schedule"));
assert!(tool_names.contains(&"check_cookie_bot_conflicts"));
assert!(tool_names.contains(&"list_cookie_bot_runs"));
assert!(tool_names.contains(&"run_cookie_bot_now"));
assert!(tool_names.contains(&"cancel_cookie_bot_run"));
assert!(tool_names.contains(&"list_cookie_bot_presets"));
assert!(tool_names.contains(&"get_cookie_bot_usage"));
}
// A tool advertised in tools/list but missing from dispatch answers "Unknown
// tool": the client can see it and cannot call it, and nothing else in the
// build notices.
//
// Asserted against the source rather than by dispatching, because half these
// tools take no arguments — calling them would reach Donut cloud, and a unit
// test that needs the network is a test that gets deleted.
#[test]
fn every_cookie_bot_tool_is_both_advertised_and_dispatchable() {
let server = McpServer::new();
let advertised: Vec<String> = server
.get_tools()
.into_iter()
.map(|tool| tool.name)
.filter(|name| name.contains("cookie_bot") || name.contains("remote"))
.collect();
let dispatched = include_str!("mcp_server.rs");
for name in &advertised {
assert!(
dispatched.contains(&format!("\"{name}\" =>")),
"tool is advertised but has no dispatch arm: {name}"
);
}
assert_eq!(
advertised.len(),
15,
"expected the full remote-fleet and cookie-bot set: {advertised:?}"
);
}
// The bot runs ONLY on the leased fleet. A profile that cannot be
// materialised on a remote host has no path to a run, and every write tool
// resolves its profile through this gate before the cloud is asked, so there
// is no argument shape that points the bot at a local-only profile.
#[test]
fn a_profile_the_bot_could_never_run_is_refused_before_the_cloud_is_asked() {
use crate::profile::types::SyncMode;
let eligible = || BrowserProfile {
id: uuid::Uuid::nil(),
name: "warm me".to_string(),
browser: "wayfern".to_string(),
version: "latest".to_string(),
sync_mode: SyncMode::Regular,
host_os: Some("macos".to_string()),
proxy_id: Some("proxy-1".to_string()),
..Default::default()
};
assert!(crate::cookie_bot::bot_precondition(
&eligible(),
&crate::remote_exit::ExitReachability::Remote
)
.is_ok());
let mut local_only = eligible();
local_only.sync_mode = SyncMode::Disabled;
assert!(
crate::cookie_bot::bot_precondition(
&local_only,
&crate::remote_exit::ExitReachability::Remote
)
.is_err(),
"a profile with no cloud copy has nothing for a host to open"
);
let mut linux = eligible();
linux.host_os = Some("linux".to_string());
assert!(
crate::cookie_bot::bot_precondition(&linux, &crate::remote_exit::ExitReachability::Remote)
.is_ok(),
"the fleet serves linux from a linux instance"
);
let mut android = eligible();
android.host_os = Some("android".to_string());
assert!(
crate::cookie_bot::bot_precondition(&android, &crate::remote_exit::ExitReachability::Remote)
.is_err(),
"the fleet has no android host to lease"
);
let mut datacenter_egress = eligible();
datacenter_egress.proxy_id = None;
datacenter_egress.vpn_id = None;
assert!(
crate::cookie_bot::bot_precondition(
&datacenter_egress,
&crate::remote_exit::ExitReachability::None
)
.is_err(),
"hours of traffic from a hosting ASN damages the identity being warmed"
);
}
// Enrolment carries only the user's own scalars. Anything describing what a
// run actually does appearing in the schema would mean the browsing model had
// leaked out of the server and into this AGPL client.
#[test]
fn the_bot_tools_expose_choices_not_behaviour() {
let server = McpServer::new();
let tools = server.get_tools();
let presets = tools
.iter()
.find(|tool| tool.name == "list_cookie_bot_presets")
.expect("list_cookie_bot_presets tool");
assert_eq!(
presets.input_schema["properties"]
.as_object()
.map(serde_json::Map::len),
Some(0),
"a preset is chosen by id; it takes no behaviour parameters"
);
let set = tools
.iter()
.find(|tool| tool.name == "set_cookie_bot_schedule")
.expect("set_cookie_bot_schedule tool");
let properties = set.input_schema["properties"]
.as_object()
.expect("schedule properties");
for leaked in [
"dwell",
"dwell_seconds",
"scroll",
"clicks",
"steps",
"actions",
"corpus",
"user_agent",
] {
assert!(
!properties.contains_key(leaked),
"the browsing model leaked into the tool contract: {leaked}"
);
}
// `platform` is accepted but not required: this machine already knows the
// profile's operating system, and a supplied one that disagrees is
// refused rather than honoured.
let required = set.input_schema["required"]
.as_array()
.expect("required fields");
assert!(!required.iter().any(|field| field == "platform"));
assert!(required.iter().any(|field| field == "profile_id"));
assert!(required.iter().any(|field| field == "preset"));
}
#[tokio::test]
async fn a_body_past_the_shared_cap_is_refused_by_the_engine_itself() {
// MAX_MESSAGE_BYTES must match what the cloud endpoint accepts. Nothing
// exercised the
// check: deleting it left every test green, so the engine and the cloud
// endpoint could quietly drift apart, which is exactly the defect that
// once made `import_profile_cookies` 413 remotely.
let server = McpServer::instance();
server.mark_engine_ready_for_tests();
// Deliberately VALID JSON-RPC, just too big. A body of junk bytes would be
// refused as unparsable whether or not the cap exists, so it would assert
// nothing about the cap, the same two-paths-one-outcome trap that made an
// earlier test in this feature pass against deleted code.
let filler = "x".repeat(McpServer::MAX_MESSAGE_BYTES);
let oversized = format!(
r#"{{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{{"name":"navigate","arguments":{{"url":"{filler}"}}}}}}"#
);
assert!(oversized.len() > McpServer::MAX_MESSAGE_BYTES);
assert!(
serde_json::from_str::<serde_json::Value>(&oversized).is_ok(),
"the fixture must be parseable, or the cap is not what rejects it"
);
assert!(matches!(
server.handle_message(None, oversized.as_bytes()).await,
McpOutcome::BadRequest
));
// And a body just under it is judged on its content, not its size, the cap
// must not be doing the rejecting for ordinary calls.
let ok = br#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#;
assert!(ok.len() < McpServer::MAX_MESSAGE_BYTES);
assert!(matches!(
server.handle_message(None, ok).await,
McpOutcome::Body { .. }
));
}
/// Blank out comments and string literals so brace counting sees only code.
///
/// Lengths and line breaks are preserved, so offsets and line numbers still
/// line up with the original source.
fn code_only(source: &str) -> String {
let chars: Vec<char> = source.chars().collect();
let mut out = String::with_capacity(chars.len());
let mut i = 0;
// Blank a span, keeping newlines so line numbers survive.
let blank = |out: &mut String, from: usize, to: usize| {
for &c in &chars[from..to] {
out.push(if c == '\n' { '\n' } else { ' ' });
}
};
while i < chars.len() {
let c = chars[i];
// A line comment: everything to the end of the line.
if c == '/' && chars.get(i + 1) == Some(&'/') {
let mut end = i;
while end < chars.len() && chars[end] != '\n' {
end += 1;
}
blank(&mut out, i, end);
i = end;
continue;
}
// A raw string: `r`, any number of `#`, then the quote. Only when the `r`
// starts a token, or the tail of an identifier such as `for` opens one.
let starts_token = i == 0 || !(chars[i - 1].is_alphanumeric() || chars[i - 1] == '_');
if c == 'r' && starts_token {
let mut hashes = 0;
while chars.get(i + 1 + hashes) == Some(&'#') {
hashes += 1;
}
if chars.get(i + 1 + hashes) == Some(&'"') {
let closing = format!("\"{}", "#".repeat(hashes));
let body_start = i + 2 + hashes;
let rest: String = chars[body_start..].iter().collect();
let end = rest
.find(&closing)
.map_or(chars.len(), |at| body_start + rest[..at].chars().count());
blank(&mut out, i, end);
i = end + closing.chars().count();
blank(&mut out, end, i.min(chars.len()));
continue;
}
}
// An ordinary string, with backslash escapes.
if c == '"' {
let mut end = i + 1;
while end < chars.len() && chars[end] != '"' {
end += if chars[end] == '\\' { 2 } else { 1 };
}
let end = end.min(chars.len());
blank(&mut out, i, end);
i = end;
if i < chars.len() {
out.push(' ');
i += 1;
}
continue;
}
out.push(c);
i += 1;
}
out
}
/// Every line (1-based) that binds a guard on the engine mutex and then
/// reaches an `.await` while that guard is still alive.
///
/// The discriminator is SCOPE, not spelling. A guard lives until the block
/// that owns it closes, so the question is whether that closing brace comes
/// before the next await:
///
/// ```ignore
/// let handle = { // safe: the block ends first
/// let inner = self.inner.lock().await;
/// inner.app_handle.as_ref().ok_or(..)?.clone()
/// };
/// something(&handle).await;
///
/// let inner = self.inner.lock().await; // held: no enclosing block
/// let handle = inner.app_handle.as_ref().ok_or(..)?.clone();
/// something(&handle).await; // the whole engine is frozen
/// ```
///
/// The previous version keyed on `.clone()` in the binding statement, which
/// the second shape also has, so it skipped a real violation as though it
/// were the safe one.
fn engine_locks_held_across_an_await(source: &str) -> (Vec<usize>, usize) {
let code = code_only(source);
let chars: Vec<char> = code.chars().collect();
// Char offset where each line begins, so a hit can be reported by line.
let mut line_of = Vec::with_capacity(chars.len() + 1);
let mut line = 1usize;
for &c in &chars {
line_of.push(line);
if c == '\n' {
line += 1;
}
}
line_of.push(line);
let needle: Vec<char> = "self.inner.lock().await;".chars().collect();
let at = |i: usize, pat: &[char]| chars[i..].starts_with(pat);
let await_pat: Vec<char> = ".await".chars().collect();
let drop_pat: Vec<char> = "drop(inner)".chars().collect();
let mut offenders = Vec::new();
let mut sites = 0usize;
let mut i = 0;
while i < chars.len() {
if !at(i, &needle) {
i += 1;
continue;
}
// Only a BOUND guard outlives its statement. `self.inner.lock().await.x`
// is a temporary that dies at the semicolon.
let line_start = chars[..i]
.iter()
.rposition(|&c| c == '\n')
.map_or(0, |newline| newline + 1);
let prefix: String = chars[line_start..i].iter().collect();
if !prefix.trim_start().starts_with("let ") {
i += 1;
continue;
}
sites += 1;
// Walk forward from the end of the lock statement. `depth` counts how
// deep we are INSIDE the guard's own block; the `}` that takes it to -1
// is the one that drops the guard.
let mut depth = 0i32;
let mut cursor = i + needle.len();
while cursor < chars.len() {
match chars[cursor] {
'{' => depth += 1,
'}' => {
if depth == 0 {
break; // the enclosing block closed: the guard is gone
}
depth -= 1;
}
_ => {
if at(cursor, &drop_pat) {
break; // released by hand before the await
}
if at(cursor, &await_pat) {
offenders.push(line_of[i]);
break;
}
}
}
cursor += 1;
}
i += needle.len();
}
(offenders, sites)
}
#[test]
fn no_handler_holds_the_engine_lock_across_an_await() {
// `handle_message` takes this same mutex to validate the session on EVERY
// request, and to serve `initialize`. A handler that keeps the guard alive
// across a browser launch therefore freezes the whole engine for the
// duration: the launching agent's follow-up calls, a second agent, the
// website console, and even a new client trying to open a session.
//
// The detector is exercised in BOTH directions first, because the previous
// one could only be pointed at this file, and a rule that has never been
// shown to fire is indistinguishable from one that cannot.
let unscoped = r#"
async fn bad(&self) -> Result<(), McpError> {
let inner = self.inner.lock().await;
let app_handle = inner
.app_handle
.as_ref()
.ok_or_else(|| McpError { code: -32000, message: "no handle".to_string(), data: None })?
.clone();
something(&app_handle).await;
Ok(())
}
"#;
let (flagged, sites) = engine_locks_held_across_an_await(unscoped);
assert_eq!(sites, 1, "the fixture must be seen as a lock site at all");
assert_eq!(
flagged,
vec![3],
"a guard bound at statement level is alive at the await, whatever the \
binding does with `.clone()`"
);
let scoped = r#"
async fn good(&self) -> Result<(), McpError> {
let app_handle = {
let inner = self.inner.lock().await;
inner
.app_handle
.as_ref()
.ok_or_else(|| McpError { code: -32000, message: "no handle".to_string(), data: None })?
.clone()
};
something(&app_handle).await;
Ok(())
}
"#;
let (flagged, sites) = engine_locks_held_across_an_await(scoped);
assert_eq!(sites, 1);
assert!(
flagged.is_empty(),
"the block expression drops the guard before the await: {flagged:?}"
);
// And a guard let go by hand is not a violation either.
let released = r#"
async fn also_good(&self) -> Result<(), McpError> {
let inner = self.inner.lock().await;
let app_handle = inner.app_handle.clone();
drop(inner);
something(&app_handle).await;
Ok(())
}
"#;
let (flagged, _) = engine_locks_held_across_an_await(released);
assert!(
flagged.is_empty(),
"an explicit drop releases it: {flagged:?}"
);
// Now the file itself.
let source = include_str!("mcp_server.rs");
let (offenders, sites) = engine_locks_held_across_an_await(source);
assert!(
sites >= 25,
"the scan found only {sites} lock sites, so it has gone blind to most of \
the file, the same way the line-window version silently matched none"
);
assert!(
offenders.is_empty(),
"these lines hold the engine mutex across an await, freezing every \
other MCP message for the duration: {offenders:?}. Clone the handle \
inside a block expression so the guard drops first."
);
}
#[test]
fn initialize_echoes_a_protocol_version_the_client_can_accept() {
// The client's check is unforgiving: the official SDK compares the
// answer against ITS OWN supported list and throws
// "Server's protocol version is not supported" on a miss. Answering with
// our newest regardless made the whole remote feature unreachable from any
// agent that had not upgraded in lockstep with us.
for asked in SUPPORTED_PROTOCOL_VERSIONS {
assert_eq!(
negotiate_protocol_version(Some(asked)),
*asked,
"a version we speak must be echoed back verbatim"
);
}
// Unknown or absent: answer with one we do support, so the client can
// decide for itself rather than be handed something meaningless.
assert_eq!(negotiate_protocol_version(None), PROTOCOL_VERSION);
assert_eq!(
negotiate_protocol_version(Some("1999-01-01")),
PROTOCOL_VERSION
);
assert_eq!(negotiate_protocol_version(Some("")), PROTOCOL_VERSION);
assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&PROTOCOL_VERSION));
}
#[tokio::test]
async fn an_older_client_is_answered_in_its_own_dialect() {
let server = McpServer::new();
server.mark_engine_ready_for_tests();
let init = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{}}}"#;
let McpOutcome::Body { body, .. } = server.handle_message(None, init).await else {
panic!("initialize must answer");
};
assert_eq!(
body["result"]["protocolVersion"], "2024-11-05",
"the client asked in 2024-11-05 and must be answered in it: {body}"
);
// And a client that names nothing still gets a usable answer.
let bare = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#;
let McpOutcome::Body { body, .. } = server.handle_message(None, bare).await else {
panic!("initialize must answer");
};
assert_eq!(body["result"]["protocolVersion"], PROTOCOL_VERSION);
}
#[test]
fn the_browser_is_never_told_to_open_a_local_file() {
// Over the CLOUD BRIDGE this is the difference between "control your
// browser" and an arbitrary local-file read reachable from the internet:
// `Page.navigate` loads `file:///…/.ssh/id_rsa` happily, and
// `get_page_content` hands the bytes back to whoever asked.
for blocked in [
"file:///etc/passwd",
"FILE:///etc/passwd",
" file:///etc/passwd ",
"file://localhost/etc/passwd",
"data:text/html,<script>fetch('/etc/passwd')</script>",
"javascript:alert(1)",
"chrome://settings",
"devtools://devtools/bundled/inspector.html",
"blob:https://example.com/abc",
"view-source:file:///etc/passwd",
"",
"/etc/passwd",
"\\\\server\\share",
] {
let refused = validate_navigable_url(blocked);
assert!(refused.is_err(), "{blocked:?} must be refused");
assert!(
refused
.unwrap_err()
.message
.contains("URL_SCHEME_NOT_ALLOWED"),
"{blocked:?} must refuse with a code the UI can translate"
);
}
// And the ones a browser is actually asked to browse still work.
for allowed in [
"http://example.com",
"https://example.com/path?q=1#frag",
"HTTPS://EXAMPLE.COM",
"about:blank",
" https://example.com ",
] {
assert!(
validate_navigable_url(allowed).is_ok(),
"{allowed:?} must be allowed"
);
}
}
#[test]
fn every_url_entry_point_is_guarded() {
// Derived from the source, and deliberately BROAD. Two earlier versions
// could not fail for the drift they existed to catch:
// - `count >= 6` plus four hand-typed handler names saw nothing new;
// - keying on the literal `get("url")` skipped any handler reading the
// same argument through `require_str`, which is this file's newer
// idiom, and the scan counted ITSELF as a fifth guarded handler, so
// the floor tolerated a real one silently dropping out.
// The test module is therefore cut off before scanning, and any method
// mentioning a "url" argument at all must validate.
let full = include_str!("mcp_server.rs");
let source = full
.split_once("\n#[cfg(test)]")
.map(|(code, _)| code)
.unwrap_or(full);
let starts: Vec<usize> = source
.match_indices("\n ")
.filter(|(i, _)| {
let rest = &source[i + 3..];
[
"fn ",
"async fn ",
"pub fn ",
"pub async fn ",
"pub(crate) fn ",
"pub(crate) async fn ",
]
.iter()
.any(|p| rest.starts_with(p))
})
.map(|(i, _)| i)
.collect();
assert!(starts.len() > 20, "method scan found {}", starts.len());
let mut unguarded = Vec::new();
let mut guarded = Vec::new();
for (n, &begin) in starts.iter().enumerate() {
let stop = starts.get(n + 1).copied().unwrap_or(source.len());
let body = &source[begin..stop];
// ANY method that reads a "url" argument from the caller, however it
// spells the read.
if !(body.contains("arguments") && body.contains(r#""url""#)) || is_tool_listing(body) {
continue;
}
let name = body
.trim_start()
.trim_start_matches("pub(crate) ")
.trim_start_matches("pub ")
.trim_start_matches("async ")
.trim_start_matches("fn ")
.split(['(', '<'])
.next()
.unwrap_or("?")
.to_string();
if body.contains("validate_navigable_url") || body.contains("require_navigable_url") {
guarded.push(name);
} else {
unguarded.push(name);
}
}
assert!(
unguarded.is_empty(),
"these handlers take a url from the caller and never validate its \
scheme, which is how `file:///…` plus a content tool became a remote \
file read: {unguarded:?}"
);
// Pinned exactly, so a handler that DISAPPEARS from the scan, renamed,
// reformatted past the matcher, or deleted, fails loudly instead of
// shrinking the set the test believes it is protecting.
guarded.sort();
assert_eq!(
guarded,
vec![
"handle_batch_run_profiles",
"handle_navigate",
"handle_open_tab",
"handle_run_profile",
"handle_run_profile_remote",
],
"the set of url-taking handlers changed; add the new one here once it \
validates, or find out why one stopped being seen"
);
}
#[test]
fn two_callers_never_share_an_element_index_cache() {
// `get_interactive_elements` stashes live element references on the page and
// hands back indices; `click_by_index` resolves an index against that stash.
// It used to be ONE `window.__donut_interactive` array for the whole page,
// which is shared, because the website console and an agent both arrive
// over the bridge, as do two runs of the same agent. Session A lists
// elements, B lists them, A's
// `click_by_index(3)` resolves against B's array and clicks the wrong
// thing while reporting success, on somebody's real browser.
let slot = interactive_cache_slot;
let a = slot("11111111-2222-3333-4444-555555555555");
let b = slot("66666666-7777-8888-9999-000000000000");
assert_ne!(a, b, "two sessions must not share a slot");
// Stable for one session, or an agent's own second call would lose the
// array its first call built.
assert_eq!(
a,
slot("11111111-2222-3333-4444-555555555555"),
"the same session must resolve to the same slot every time"
);
// DISTINCTNESS, which the previous key only claimed. Truncating at 64
// characters and mapping everything outside [A-Za-z0-9_] to `_` collapsed
// both of these pairs onto ONE slot, and a collision here is precisely the
// wrong click the key exists to prevent.
let shared_prefix = "s".repeat(64);
assert_ne!(
slot(&format!("{shared_prefix}-one")),
slot(&format!("{shared_prefix}-two")),
"two ids agreeing on a 64-character prefix must not share a slot"
);
assert_ne!(
slot("abc-def"),
slot("abc_def"),
"ids differing only in a character the old key erased must not share a slot"
);
// Quoted JS string literals, because they are substituted into
// `window[...]`. An unquoted identifier would read a different global, and
// the session half is the only caller-supplied part, so nothing in it may
// close the quote or paste an expression.
let overlong = "z".repeat(5_000);
for candidate in [
a,
slot("'; window.x = 1; //"),
slot("../../etc\\passwd"),
slot(""),
slot(overlong.as_str()),
] {
assert!(
candidate.starts_with('\'') && candidate.ends_with('\''),
"the slot must be a quoted JS string, got {candidate}"
);
let inside = &candidate[1..candidate.len() - 1];
assert!(
inside
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_'),
"a caller-supplied id must not survive into the slot unsanitised: {candidate}"
);
assert!(
candidate.len() < 128,
"a caller must not be able to grow the evaluated script: {} chars",
candidate.len()
);
}
// And every script that touches the cache goes through the substitution
// rather than naming a slot directly, or one of them would keep writing to
// the shared global while the others moved.
let source = include_str!("mcp_server.rs");
let production = source
.split_once("\n#[cfg(test)]")
.map_or(source, |(code, _)| code);
assert!(
!production.contains("window.__donut_interactive"),
"no script may name the shared global directly any more"
);
assert_eq!(
production.matches("window[__CACHE__]").count()
+ production.matches("window[{cache}]").count()
+ production.matches("window[{slot}]").count(),
9,
"the nine cache sites (one write, six reads, two in the teardown) must \
all be parameterised"
);
}
#[tokio::test]
async fn an_index_tool_without_a_session_is_refused_not_guessed_at() {
// The hole the per-session key left open. `handle_message` serves a client
// that never called `initialize`, and the bridge passes whatever `sessionId`
// the relay frame carried, including none, so every sessionless caller
// used to land on ONE literal `anon` slot per transport. Two of them then
// shared an array, and session A's `click_by_index(3)` clicked whatever sat
// third in B's snapshot and reported "Clicked element at index 3".
//
// There is no safe slot to give such a caller, so it is refused. The
// refusal has to come FIRST, before argument parsing and before anything
// touches the page.
let server = McpServer::new();
let sessionless = McpCaller {
session: None,
toolsets: None,
};
let with_session = McpCaller {
session: Some("11111111-2222-3333-4444-555555555555"),
toolsets: None,
};
// Deliberately complete arguments: the refusal must be about the missing
// session, not about anything the caller forgot to send.
let args = serde_json::json!({
"profile_id": "00000000-0000-0000-0000-000000000000",
"index": 3,
"text": "hello",
});
let refusals = [
server
.handle_get_interactive_elements(sessionless, &args)
.await
.expect_err("listing must refuse a sessionless caller"),
server
.handle_click_by_index(sessionless, &args)
.await
.expect_err("clicking by index must refuse a sessionless caller"),
server
.handle_type_by_index(sessionless, &args)
.await
.expect_err("typing by index must refuse a sessionless caller"),
];
for refusal in &refusals {
assert_eq!(
refusal.code, -32600,
"a missing session is a bad request, not a tool failure: {}",
refusal.message
);
assert!(
refusal.message.contains("initialize"),
"the refusal must tell the agent what to do about it: {}",
refusal.message
);
}
// And the refusal is about the SESSION, not a coincidence of this fixture:
// the same call WITH a session gets past the gate and fails later, on the
// profile that does not exist.
let later = server
.handle_click_by_index(with_session, &args)
.await
.expect_err("no such profile in a unit test");
assert!(
!later.message.contains("initialize"),
"a caller that HAS a session must not be refused for lacking one: {}",
later.message
);
}
#[tokio::test]
async fn ending_a_session_releases_the_page_globals_it_left_behind() {
// Every session mints its own page global, and before this nothing ever
// deleted one: `end_session` dropped the server-side record only, and no
// CDP call removed the array. A long-lived tab therefore accumulated one
// array of live element references per session that had ever listed it,
// pinning detached nodes for as long as the page stayed open.
let server = McpServer::new();
server.mark_engine_ready_for_tests();
let init = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#;
let McpOutcome::Body { new_session_id, .. } = server.handle_message(None, init).await else {
panic!("initialize must mint a session");
};
let session = new_session_id.expect("session id");
let slot = interactive_cache_slot(&session);
server
.remember_cached_page(&session, "profile-a", &slot)
.await;
server
.remember_cached_page(&session, "profile-b", &slot)
.await;
// Recorded once per page, not once per call.
server
.remember_cached_page(&session, "profile-a", &slot)
.await;
assert_eq!(
server.inner.lock().await.sessions[&session]
.cached_pages
.len(),
2,
"a session must remember every page it wrote a snapshot to, once each"
);
// Ending it takes the pages with it. Neither profile exists here, so the
// CDP delete cannot land, which is exactly the path that must still leave
// no server-side record behind, and must still return promptly.
server.end_session(&session).await;
let inner = server.inner.lock().await;
assert!(
!inner.sessions.contains_key(&session),
"the session itself must be gone"
);
drop(inner);
// A snapshot taken after the teardown must not resurrect the session.
server
.remember_cached_page(&session, "profile-c", &slot)
.await;
assert!(
!server.inner.lock().await.sessions.contains_key(&session),
"a late write must not recreate a session nothing will ever clean up"
);
}
#[test]
fn one_page_cannot_accumulate_unbounded_element_caches() {
// The teardown above only fires when a session ENDS, and no first-party
// client sends `end_session`, an evicted, crashed or simply abandoned
// session never will. So the page bounds itself too: the enumeration
// script keeps a registry of the slots it has written and deletes the
// oldest once the cap is passed.
let script = INTERACTIVE_ELEMENTS_JS
.replace("__MAX_CHARS__", "40000")
.replace("__CACHE__", "'__donut_interactive_bridge_abc'")
.replace("__REGISTRY__", INTERACTIVE_SLOT_REGISTRY)
.replace("__MAX_SLOTS__", &MAX_CACHE_SLOTS_PER_PAGE.to_string());
assert!(
!script.contains("__MAX_CHARS__")
&& !script.contains("__CACHE__")
&& !script.contains("__REGISTRY__")
&& !script.contains("__MAX_SLOTS__"),
"every placeholder must be substituted, or the page throws instead of listing"
);
assert!(
script.contains(&format!("kept.length > {MAX_CACHE_SLOTS_PER_PAGE}")),
"the cap must be a real number in the emitted script"
);
assert!(
script.contains("delete window[evicted]"),
"passing the cap must actually delete the evicted slot, not merely stop \
tracking it"
);
assert!(
(1..=32).contains(&MAX_CACHE_SLOTS_PER_PAGE),
"a cap of {MAX_CACHE_SLOTS_PER_PAGE} is either no cap at all or too tight \
for the callers legitimately driving one page"
);
// The teardown deletes the same global the script writes, or `end_session`
// clears a slot nobody uses while the real one leaks on.
let production = include_str!("mcp_server.rs")
.split_once("\n#[cfg(test)]")
.map_or("", |(code, _)| code);
assert!(
production.contains("delete window[{slot}]"),
"end_session must delete the page global itself"
);
assert!(
production.contains("session.cached_pages"),
"end_session must take the pages from the session it removes"
);
}
#[tokio::test]
async fn the_session_the_transport_validated_is_the_one_the_cache_keys_on() {
// The slot is only per-session if the session actually REACHES the cache.
// `handle_message` is the one place the bridge hands a session id in,
// so the wiring from there down to the three cache sites is asserted here:
// without it `interactive_cache_slot` could be perfectly correct and every
// caller would still be handed a slot chosen from nothing.
let production = include_str!("mcp_server.rs")
.split_once("\n#[cfg(test)]")
.map_or("", |(code, _)| code);
let flattened: String = production.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(
flattened.contains("McpCaller { session: session_id"),
"handle_message must pass the caller's session on to the dispatcher, or \
every caller is refused the index tools it is entitled to"
);
for handler in [
"handle_get_interactive_elements",
"handle_click_by_index",
"handle_type_by_index",
] {
let body = production
.split(&format!("async fn {handler}("))
.nth(1)
.unwrap_or_else(|| panic!("{handler} must exist"));
let signature = &body[..body.find(") ->").unwrap_or(body.len())];
assert!(
signature.contains("caller: McpCaller"),
"{handler} must be told WHO is asking, not just how they got here: \
{signature}"
);
}
// End to end: two sessions, one transport, and the engine must not answer
// the second one's `click_by_index` from the first one's snapshot. Both
// calls fail here (no browser in a unit test), so what is asserted is that
// the sessions are distinct and both survive the session check, the
// per-session slot is asserted directly above.
let server = McpServer::new();
server.mark_engine_ready_for_tests();
let init = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#;
let mut ids = Vec::new();
for _ in 0..2 {
let McpOutcome::Body { new_session_id, .. } = server.handle_message(None, init).await else {
panic!("initialize must answer");
};
ids.push(new_session_id.expect("initialize must mint a session id"));
}
assert_ne!(
ids[0], ids[1],
"two initializes on one transport must be two different sessions"
);
assert_ne!(
interactive_cache_slot(&ids[0]),
interactive_cache_slot(&ids[1]),
"two live sessions on one transport must not resolve to one slot"
);
// Both survive the session check, so neither is refused for a reason other
// than the one under test.
let ping = br#"{"jsonrpc":"2.0","id":2,"method":"ping"}"#;
for id in &ids {
assert!(
matches!(
server.handle_message(Some(id.as_str()), ping).await,
McpOutcome::Body { .. }
),
"a freshly minted session must be usable"
);
}
}
#[test]
fn a_typing_request_that_would_run_for_hours_is_refused_before_it_starts() {
// The DECISION, exercised. An earlier version of this test asserted only
// that the plan arithmetic crossed the bound and that the constant appeared
// before `target.connect()` in the source, and neither noticed the guard
// being turned into `if false && planned > MAX_TYPING_SECONDS`, nor the
// bound being raised past anything reachable. Both mutations were MISSED.
// Calling the function that makes the decision catches both.
let ordinary = plan_typing("hello there", None, MAX_TYPING_SECONDS).expect("an ordinary field");
assert!(!ordinary.is_empty(), "a real plan must be produced");
// `session_wpm` is floored at 10 in human_typing.rs, so this is the slowest
// a plan can be, and the text length, which nothing bounds, is what makes
// the hours reachable.
// 1,000 characters, not 20,000. At the wpm floor a keystroke costs
// 60 / (10 * 5) = 1.2s, so ~250 characters already crosses the 300s bound -
// and generating a 20,000 character plan took this one test 24 seconds, on
// a suite that otherwise runs in seven.
// Still within MAX_TYPING_CHARS, so this exercises the DURATION bound
// rather than the length one: 1,000 characters at the wpm floor plans in
// well under a second but would take over an hour to type.
let refusal = plan_typing(&"a".repeat(1_000), Some(10.0), MAX_TYPING_SECONDS)
.expect_err("1,000 characters at the slowest rate must be refused");
assert_eq!(refusal.code, -32602);
assert!(
refusal.message.contains("TYPING_TOO_LONG"),
"the refusal must carry a translatable code: {}",
refusal.message
);
// The numbers travel with it, so the caller is told what to change rather
// than only that it was too much.
let body: serde_json::Value =
serde_json::from_str(&refusal.message).expect("the code envelope is JSON");
assert_eq!(body["params"]["limit"], "300");
assert!(
body["params"]["seconds"]
.as_str()
.and_then(|s| s.parse::<f64>().ok())
.is_some_and(|seconds| seconds > MAX_TYPING_SECONDS),
"the reported duration must be the one that broke the bound: {body}"
);
// The LENGTH bound must bite before the plan is built, or the caller pays
// the superlinear planning cost of an arbitrarily long string first. Timed,
// because "it refuses" is not the property, "it refuses CHEAPLY" is.
let huge = "a".repeat(400_000);
let started = std::time::Instant::now();
let long_refusal = plan_typing(&huge, None, MAX_TYPING_SECONDS).expect_err("400,000 chars");
let took = started.elapsed();
assert!(
long_refusal.message.contains("TYPING_TOO_LONG"),
"{}",
long_refusal.message
);
assert!(
took < std::time::Duration::from_millis(500),
"the refusal must not require planning the text first; took {took:?}"
);
// And the sending path can no longer refuse at all: it is handed a plan
// that was already accepted.
let source = include_str!("mcp_server.rs");
let production = source
.split_once("\n#[cfg(test)]")
.map_or(source, |(code, _)| code);
let sender = production
.split("async fn send_planned_keystrokes(")
.nth(1)
.expect("send_planned_keystrokes must exist");
let sender = &sender[..sender.find("\n }").unwrap_or(sender.len())];
assert!(
!sender.contains("plan_typing"),
"the sender must take the accepted plan, not decide for itself, \
deciding here is what put the refusal after the field was emptied"
);
assert!(
!production.contains("send_human_keystrokes"),
"the plan-it-yourself entry point must be gone, or a handler can call \
it and reintroduce the late refusal"
);
}
#[test]
fn a_refused_typing_request_has_not_already_emptied_the_field() {
// The ORDER, which the duration bound alone never pinned. Both typing
// handlers used to focus the element and run `el.value = ''` first and call
// `plan_typing` only afterwards, so a TYPING_TOO_LONG was a lie: the server
// answered "refused" with the customer's form field already wiped. Nothing
// may touch the page before the plan is accepted, on EITHER branch of
// `clear_first`.
let production = include_str!("mcp_server.rs")
.split_once("\n#[cfg(test)]")
.map_or("", |(code, _)| code);
for handler in ["handle_type_text", "handle_type_by_index"] {
let rest = production
.split(&format!("async fn {handler}("))
.nth(1)
.unwrap_or_else(|| panic!("{handler} must exist"));
let body = &rest[..rest.find("\n async fn ").unwrap_or(rest.len())];
let plan = body
.find("plan_typing(text, wpm, MAX_BRIDGE_TYPING_SECONDS)?")
.unwrap_or_else(|| panic!("{handler} must plan through the bounded builder"));
assert_eq!(
body.matches("plan_typing(").count(),
1,
"{handler} must plan exactly once, before the page is touched"
);
// Both branches of `clear_first` are present, and BOTH are built after
// the plan. The clearing branch is the one that destroys data; the other
// still focuses and scrolls, which is a mutation the caller can see.
assert_eq!(
body.matches("el.value = ''").count(),
1,
"{handler} must still have exactly one clearing branch to order"
);
assert_eq!(
body.matches("el.focus();").count(),
2,
"{handler} must have both a clearing and a non-clearing focus branch"
);
for mutation in [
"el.value = ''",
"el.focus();",
"scrollIntoView",
"let focus_js",
".send_cdp(",
"send_planned_keystrokes",
"Input.insertText",
] {
let at = body
.find(mutation)
.unwrap_or_else(|| panic!("{handler} no longer contains {mutation}"));
assert!(
plan < at,
"{handler} reaches {mutation:?} at {at} before planning at {plan}: a \
refusal raised after that point has already changed the page it \
claims it did not touch"
);
}
// And the refusal really is a refusal: `?` on the plan, not a swallowed
// error that lets the handler carry on and clear the field anyway.
assert!(
body.contains("Some(plan_typing(text, wpm, MAX_BRIDGE_TYPING_SECONDS)?)"),
"{handler} must propagate the refusal rather than absorb it"
);
}
}
#[test]
fn an_mcp_caller_may_type_for_less_than_the_relay_will_wait() {
// A call over the bridge is answered with a timeout if it runs too long, so
// a plan that would type for longer is a guaranteed failure that still holds
// a process-wide permit for its whole duration. The local REST API keeps
// the five minutes: nothing upstream times it out.
assert_eq!(MAX_TYPING_SECONDS, 300.0);
assert_eq!(MAX_BRIDGE_TYPING_SECONDS, 80.0);
// The MCP budget must stay under the relay's 90 s call budget.
const { assert!(MAX_BRIDGE_TYPING_SECONDS < 90.0) };
// Exercised, not merely asserted: the same text is refused over MCP and
// accepted over REST. Each plan is built afresh with its
// own randomness, so the fixture sits far from both bounds: 1,200
// characters at 200 wpm plan to roughly 160 seconds, and the session rate
// is sampled with a standard deviation of 10 wpm (five percent here; at
// the 10 wpm floor it was a factor of three, which is why the fixture is
// not typed at the floor). Fatigue makes the plan superlinear, so the
// count is not to be scaled by eye.
let text = "a".repeat(1_200);
let over_rest = plan_typing(&text, Some(200.0), MAX_TYPING_SECONDS)
.expect("about 160 seconds is inside the REST budget");
let planned = over_rest.last().map_or(0.0, |event| event.time);
assert!(
planned > 110.0 && planned < 250.0,
"the fixture must sit well clear of both budgets, planned {planned}s"
);
let over_bridge = plan_typing(&text, Some(200.0), MAX_BRIDGE_TYPING_SECONDS)
.expect_err("the same text must be refused over MCP");
let body: serde_json::Value = serde_json::from_str(&over_bridge.message).unwrap();
assert_eq!(body["code"], "TYPING_TOO_LONG");
assert_eq!(
body["params"]["limit"], "80",
"the refusal must name the budget that applied, not the REST one"
);
}
#[test]
fn a_stored_proxy_loses_its_secrets_and_nothing_else_under_redaction() {
let mut proxy = serde_json::json!({
"id": "p1",
"name": "Berlin",
"proxy_settings": {
"proxy_type": "socks5",
"host": "proxy.example",
"port": 1080,
"username": "user",
"password": "hunter2",
"vless_uri": "vless://uuid@host:443?security=reality#name"
},
"geo_country": "DE",
"dynamic_proxy_url": "https://lists.example/rotate?key=SECRET",
"rotation_url": "https://provider.example/change-ip?token=ROTATE"
});
redact_proxy_secrets(&mut proxy);
assert_eq!(proxy["proxy_settings"]["password"], "[redacted]");
assert_eq!(proxy["proxy_settings"]["vless_uri"], "[redacted]");
assert_eq!(proxy["dynamic_proxy_url"], "[redacted]");
assert_eq!(proxy["rotation_url"], "[redacted]");
// Everything an agent needs to choose a proxy survives.
assert_eq!(proxy["id"], "p1");
assert_eq!(proxy["name"], "Berlin");
assert_eq!(proxy["proxy_settings"]["host"], "proxy.example");
assert_eq!(proxy["proxy_settings"]["port"], 1080);
assert_eq!(proxy["proxy_settings"]["username"], "user");
assert_eq!(proxy["geo_country"], "DE");
let text = proxy.to_string();
assert!(!text.contains("hunter2") && !text.contains("SECRET") && !text.contains("vless://"));
assert!(!text.contains("ROTATE"));
// A proxy with no secrets is left exactly as it was: no field is invented
// just to say it was redacted.
let mut bare = serde_json::json!({
"id": "p2",
"name": "Plain",
"proxy_settings": {
"proxy_type": "http",
"host": "h",
"port": 8080,
"username": null,
"password": null
}
});
let before = bare.clone();
redact_proxy_secrets(&mut bare);
assert_eq!(bare, before);
}
#[test]
fn the_proxy_readers_always_redact() {
// `handle_list_proxies` and `handle_get_proxy` serialize a `StoredProxy`
// whole, and every MCP caller holds an account credential rather than this
// machine, so both must route their answer through the redaction.
let production = include_str!("mcp_server.rs")
.split_once("\n#[cfg(test)]")
.map_or("", |(code, _)| code);
for handler in ["handle_list_proxies", "handle_get_proxy"] {
let rest = production
.split(&format!("async fn {handler}("))
.nth(1)
.unwrap_or_else(|| panic!("{handler} must exist"));
let body = &rest[..rest.find("\n async fn ").unwrap_or(rest.len())];
assert_eq!(
body.matches("redact_proxy_secrets(").count(),
1,
"{handler} must redact in exactly one place"
);
}
}
#[tokio::test]
async fn exporting_proxies_is_refused_before_the_arguments_are_read() {
// The export exists to write every password and VLESS URI out in full,
// which is what the redaction above withholds from a remote caller. The
// refusal sits at the gate, so a well-formed export and a malformed one
// are indistinguishable from outside.
let server = McpServer::new();
server.mark_engine_ready_for_tests();
for arguments in [r#"{"format":"json"}"#, r#"{}"#] {
let call = format!(
r#"{{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{{"name":"export_proxies","arguments":{arguments}}}}}"#
);
let McpOutcome::Body { body, .. } = server.handle_message(None, call.as_bytes()).await else {
panic!("expected an answer");
};
assert!(
body["error"]["message"]
.as_str()
.unwrap_or_default()
.contains("TOOL_IS_LOCAL_ONLY"),
"export_proxies must be refused: {body}"
);
}
// And the list is pinned: only the export is refused by name, and it is.
assert_eq!(SECRET_EXPORT_TOOLS, &["export_proxies"]);
}
#[test]
fn every_tool_that_reads_a_local_path_is_on_the_local_only_list() {
// Derived from the source, and the list is PINNED. Iterating
// LOCAL_PATH_TOOLS to check each entry is refused is a tautology -
// deleting an entry just shortens the loop, so the membership itself is
// asserted, and a handler that reads a path without being listed fails.
let full = include_str!("mcp_server.rs");
let source = full
.split_once("\n#[cfg(test)]")
.map(|(code, _)| code)
.unwrap_or(full);
let starts: Vec<usize> = source
.match_indices("\n ")
.filter(|(i, _)| {
let rest = &source[i + 3..];
["fn ", "async fn ", "pub fn ", "pub async fn "]
.iter()
.any(|p| rest.starts_with(p))
})
.map(|(i, _)| i)
.collect();
let mut readers = Vec::new();
for (n, &begin) in starts.iter().enumerate() {
let stop = starts.get(n + 1).copied().unwrap_or(source.len());
let body = &source[begin..stop];
// `get_tools` only lists schemas; a batch tool's schema names `arguments`.
if !body.contains("arguments") || is_tool_listing(body) {
continue;
}
if body.contains(r#""path""#) || body.contains(r#""folder""#) {
let name = body
.trim_start()
.trim_start_matches("pub ")
.trim_start_matches("async ")
.trim_start_matches("fn ")
.split(['(', '<'])
.next()
.unwrap_or("?")
.trim_start_matches("handle_")
.to_string();
readers.push(name);
}
}
// The scan above only sees a path named as a LITERAL argument key in the
// handler. `import_browser_profiles` takes its path as `items[].source_path`
// on a deserialized struct, so no such literal appears anywhere in its body
// and it sat off the list, reachable from the internet, until a review
// caught it by reading the struct instead. The declared schema is the
// contract that does show nested arguments, so it is walked here too and
// the two sources are unioned: a path reachable through EITHER the argument
// keys or the published schema has to be refused over the bridge.
fn path_like(name: &str) -> bool {
let lowered = name.to_ascii_lowercase();
lowered.contains("path")
|| lowered.contains("folder")
|| lowered.contains("directory")
|| lowered == "dir"
|| lowered.ends_with("_dir")
}
fn walk(schema: &serde_json::Value, found: &mut bool) {
let serde_json::Value::Object(map) = schema else {
return;
};
if let Some(serde_json::Value::Object(props)) = map.get("properties") {
for (key, value) in props {
if path_like(key) {
*found = true;
}
walk(value, found);
}
}
if let Some(items) = map.get("items") {
walk(items, found);
}
for branch in ["anyOf", "oneOf", "allOf"] {
if let Some(serde_json::Value::Array(options)) = map.get(branch) {
for option in options {
walk(option, found);
}
}
}
}
for tool in McpServer::new().get_tools() {
let mut found = false;
walk(&tool.input_schema, &mut found);
if found {
readers.push(tool.name.clone());
}
}
readers.sort();
readers.dedup();
let mut listed: Vec<String> = LOCAL_PATH_TOOLS.iter().map(|t| t.to_string()).collect();
listed.sort();
assert_eq!(
readers, listed,
"a handler reads a caller-supplied filesystem path but is not refused \
over the bridge (or the list names one that no longer reads a path)"
);
}
#[tokio::test]
async fn a_tool_that_reads_this_machine_is_refused_over_the_bridge() {
// `add_extension` takes a caller-supplied path, `fs::read`s it, stores the
// bytes, and the sync engine uploads them, an arbitrary local-file read
// with the same shape as the `file://` hole the URL allowlist closed, but
// reachable from the internet with an account credential.
//
// A remote caller cannot know this machine's filesystem, so there is no
// legitimate remote use to preserve: refusing is strictly better than
// guessing at safe roots.
let server = McpServer::new();
server.mark_engine_ready_for_tests();
for tool in LOCAL_PATH_TOOLS {
let call = format!(
r#"{{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{{"name":"{tool}","arguments":{{"path":"/etc/passwd","folder":"/etc"}}}}}}"#
);
let McpOutcome::Body { body, .. } = server.handle_message(None, call.as_bytes()).await else {
panic!("expected an answer for {tool}");
};
let message = body["error"]["message"].as_str().unwrap_or_default();
assert!(
message.contains("TOOL_IS_LOCAL_ONLY"),
"{tool} must be refused over the bridge with a translatable code: {body}"
);
}
}
#[tokio::test]
async fn a_nested_path_is_refused_over_the_bridge_in_its_real_shape() {
// The loop above sends `{"path": ..., "folder": ...}` to every listed
// tool, which is not the shape `import_browser_profiles` actually takes -
// its path rides inside `items[].source_path`. Sending the real payload is
// the difference between proving the NAME is on a list and proving the
// CALL an attacker would make is refused.
let server = McpServer::new();
server.mark_engine_ready_for_tests();
let call = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "import_browser_profiles",
"arguments": {
"items": [{
"source_path": "/Users/someone/Library/Application Support/Google/Chrome/Default",
"new_profile_name": "stolen"
}]
}
}
})
.to_string();
let McpOutcome::Body { body, .. } = server.handle_message(None, call.as_bytes()).await else {
panic!("expected an answer");
};
assert!(
body["error"]["message"]
.as_str()
.unwrap_or_default()
.contains("TOOL_IS_LOCAL_ONLY"),
"a remote caller must not be able to name a directory on this disk: {body}"
);
// The refusal must happen BEFORE the arguments are even parsed, so a
// malformed remote payload cannot be told apart from a well-formed one.
// Otherwise the error text itself answers "does this path exist".
let probe = serde_json::json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": { "name": "import_browser_profiles", "arguments": {} }
})
.to_string();
let McpOutcome::Body { body, .. } = server.handle_message(None, probe.as_bytes()).await else {
panic!("expected an answer");
};
assert!(
body["error"]["message"]
.as_str()
.unwrap_or_default()
.contains("TOOL_IS_LOCAL_ONLY"),
"the gate must sit ahead of argument parsing: {body}"
);
}
#[tokio::test]
async fn the_session_map_is_bounded() {
// Nothing evicts a session except an explicit `end_session`, and no
// first-party client sends one, so without a cap the map grows for the
// life of the process. The oldest is evicted rather than the newest
// refused: the caller asking for a session is the one actually present.
let server = McpServer::new();
server.mark_engine_ready_for_tests();
let init = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#;
let mut first = None;
for i in 0..(MAX_SESSIONS + 8) {
let McpOutcome::Body { new_session_id, .. } = server.handle_message(None, init).await else {
panic!("initialize must mint a session");
};
if i == 0 {
first = new_session_id;
}
}
assert!(
server.inner.lock().await.sessions.len() <= MAX_SESSIONS,
"the session map must stay bounded"
);
// The oldest went first, and the map still works for a fresh session.
let ping = br#"{"jsonrpc":"2.0","id":2,"method":"ping"}"#;
assert!(matches!(
server
.handle_message(Some(&first.expect("first session")), ping)
.await,
McpOutcome::UnknownSession
));
}
#[tokio::test]
async fn the_cap_evicts_what_is_idle_not_what_is_busy() {
// Evicting by CREATION time threw out the wrong session every time: a
// long-lived agent's is by definition the oldest, so a wall of abandoned
// sessions from closed browser tabs would evict the one client actually
// working, and the official MCP SDK does not re-initialize on the 404
// that follows, so that agent stays wedged.
let server = McpServer::new();
server.mark_engine_ready_for_tests();
let init = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#;
let ping = br#"{"jsonrpc":"2.0","id":2,"method":"ping"}"#;
let McpOutcome::Body { new_session_id, .. } = server.handle_message(None, init).await else {
panic!("initialize must mint a session");
};
let agent = new_session_id.expect("session id");
// Fill the map, keeping the agent's session in active use throughout.
let mut newest = None;
for i in 0..(MAX_SESSIONS + 16) {
if let McpOutcome::Body { new_session_id, .. } = server.handle_message(None, init).await {
newest = new_session_id;
}
if i % 4 == 0 {
assert!(
matches!(
server.handle_message(Some(&agent), ping).await,
McpOutcome::Body { .. }
),
"the busy session must survive: it is the one being used"
);
}
}
assert!(
server.inner.lock().await.sessions.len() <= MAX_SESSIONS,
"still bounded"
);
assert!(
matches!(
server.handle_message(Some(&agent), ping).await,
McpOutcome::Body { .. }
),
"the session in continuous use must outlive the idle ones"
);
// And the newest survives too. Without this the test passes for an
// eviction policy that throws out whatever just arrived, which keeps the
// map bounded and the busy session alive while making every new client
// unable to hold a session at all.
assert!(
matches!(
server
.handle_message(Some(&newest.expect("a newest session")), ping)
.await,
McpOutcome::Body { .. }
),
"a freshly minted session must not be the one evicted"
);
}
#[tokio::test]
async fn every_tool_is_in_a_toolset_and_a_selection_lists_only_its_toolsets() {
let server = McpServer::new();
let advertised: std::collections::HashSet<String> = server
.get_tools()
.into_iter()
.map(|tool| tool.name)
.collect();
for name in &advertised {
assert!(
crate::mcp_toolsets::toolset_of(name).is_some(),
"{name} is in no toolset, so a client that picks toolsets never sees it"
);
}
for (set, tools) in crate::mcp_toolsets::TOOLSETS {
for tool in *tools {
assert!(
advertised.contains(*tool),
"{set} lists {tool}, which is no tool"
);
}
}
let selection = crate::mcp_toolsets::parse(Some("network")).unwrap();
let listed = server
.handle_tools_list(Some(&selection))
.await
.expect("tools list");
let names: Vec<&str> = listed["tools"]
.as_array()
.unwrap()
.iter()
.map(|tool| tool["name"].as_str().unwrap())
.collect();
assert!(names.contains(&"list_profiles") && names.contains(&"rotate_proxy_ip"));
assert!(!names.contains(&"list_cookie_bot_schedules"));
assert!(names.len() < advertised.len());
let list = listed["tools"]
.as_array()
.unwrap()
.iter()
.find(|tool| tool["name"] == "list_profiles")
.unwrap();
assert_eq!(list["outputSchema"]["type"], "object");
}
#[tokio::test]
async fn the_launch_settings_an_agent_can_set_are_the_ones_it_can_read() {
let server = McpServer::new();
let tools = server.handle_tools_list(None).await.expect("tools list");
let update = tools["tools"]
.as_array()
.expect("tools array")
.iter()
.find(|tool| tool["name"] == "update_profile_fingerprint")
.expect("update_profile_fingerprint is advertised");
let properties = &update["inputSchema"]["properties"];
for field in ["restore_session", "webrtc_mode"] {
assert!(
properties.get(field).is_some(),
"{field} must be settable through the tool that owns the Wayfern config"
);
}
assert_eq!(
properties["webrtc_mode"]["enum"],
serde_json::json!(["auto", "tcp_only", "block"]),
"the modes offered must be the modes the launcher understands"
);
// A mode the launcher would silently read as `auto` is refused instead.
assert!(crate::wayfern_manager::WebRtcMode::parse("sideways").is_none());
}
#[test]
fn proxy_tool_schema_exposes_vless_reality_without_requiring_regular_endpoint_fields() {
let server = McpServer::new();
let tools = server.get_tools();
let create = tools
.iter()
.find(|tool| tool.name == "create_proxy")
.expect("create_proxy tool");
let properties = &create.input_schema["properties"];
for xray_type in ["vless", "vmess", "trojan", "hysteria2"] {
assert!(properties["proxy_type"]["enum"]
.as_array()
.is_some_and(|values| values.iter().any(|value| value == xray_type)));
}
assert!(properties["vless_uri"].is_object());
let required = create.input_schema["required"]
.as_array()
.expect("required fields");
assert!(required.iter().any(|field| field == "name"));
assert!(required.iter().any(|field| field == "proxy_type"));
assert!(!required.iter().any(|field| field == "host"));
assert!(!required.iter().any(|field| field == "port"));
}
#[test]
fn rate_limit_only_classifies_browser_automation_tools() {
let request = |method: &str, name: Option<&str>| McpRequest {
jsonrpc: "2.0".to_string(),
id: Some(serde_json::json!(1)),
method: method.to_string(),
params: name.map(|name| serde_json::json!({ "name": name, "arguments": {} })),
};
for name in [
"run_profile",
"kill_profile",
"batch_run_profiles",
"batch_stop_profiles",
"start_sync_session",
"navigate",
"screenshot",
"evaluate_javascript",
"click_element",
"type_text",
"get_page_content",
"get_page_info",
"get_interactive_elements",
"click_by_index",
"type_by_index",
// The agent surface reads and drives the same browser.
"perceive_page",
"resolve_locator",
"click_locator",
"type_locator",
"extract_structured",
"pick_element",
"select_option",
"file_upload",
// A capture reads every request the browser makes.
"start_network_capture",
"list_network_requests",
"get_network_request",
// Leases a remote host for up to two hours and spends the pooled
// remote-hour budget.
"run_cookie_bot_now",
"run_profile_remote",
// Reaches the fleet, like the remote-session stop it mirrors.
"cancel_cookie_bot_run",
"stop_remote_session",
] {
assert!(
McpServer::is_automation_tool_call(&request("tools/call", Some(name))),
"automation tool was not limited: {name}"
);
}
for name in [
"list_profiles",
// Configuration, not automation: nothing is leased. Metering it would
// throttle an agent enrolling many profiles, and it is not what spends
// the account's hours.
"set_cookie_bot_schedule",
"delete_cookie_bot_schedule",
"list_cookie_bot_schedules",
"get_cookie_bot_schedule",
"check_cookie_bot_conflicts",
"list_cookie_bot_runs",
"list_cookie_bot_presets",
"get_cookie_bot_usage",
"get_remote_hours_quota",
"list_remote_sessions",
"get_remote_session",
] {
assert!(
!McpServer::is_automation_tool_call(&request("tools/call", Some(name))),
"free or non-leasing tool was limited: {name}"
);
}
assert!(!McpServer::is_automation_tool_call(&request(
"tools/list",
None
)));
}
// --- The agent surface --------------------------------------------------
//
// Driven against the fake Wayfern socket in `wayfern_cdp::test_support`,
// because the property that matters, WHICH engine a profile gets and what
// that engine puts on the wire, only shows up when something answers.
fn agent_context_for(version: &str, target: CdpTarget) -> AgentContext {
AgentContext::new(
BrowserProfile {
id: uuid::Uuid::nil(),
name: "p".to_string(),
browser: "wayfern".to_string(),
version: version.to_string(),
..Default::default()
},
target,
)
}
#[tokio::test]
async fn a_152_profile_clicks_through_vellum_and_an_older_one_through_dispatch() {
use crate::wayfern_cdp::test_support::{fake_browser, methods, Fake};
let request: AgentClickRequest = serde_json::from_value(serde_json::json!({
"locator": { "role": "button", "name": "Save" }
}))
.unwrap();
// The 152 engine: the native resolver names the node, the DOM confirms the
// node carries what was matched and says where it is on screen, and a real
// pointer strikes it. No script runs in the page and nothing is dispatched
// synthetically.
let (target, frames) = fake_browser(Fake::Cooperative).await;
let ctx = agent_context_for("152.0.7977.64", target);
assert_eq!(ctx.engine, Engine::Wayfern);
let clicked = agent_click_locator(&ctx, &request)
.await
.expect("a cooperative browser completes the click");
assert!(clicked.clicked);
assert_eq!(clicked.engine, Engine::Wayfern);
assert_eq!(clicked.matched.signature, "s7");
let sent = methods(&frames);
let vellum: Vec<&str> = sent
.iter()
.filter(|m| m.starts_with("Vellum."))
.map(String::as_str)
.collect();
assert_eq!(
vellum,
vec![
"Vellum.acquire",
"Vellum.glide",
"Vellum.strike",
"Vellum.release"
]
);
assert!(sent.iter().any(|m| m == "Wayfern.resolveLocator"));
assert!(sent.iter().any(|m| m == "DOM.describeNode"));
assert!(sent.iter().any(|m| m == "DOM.getContentQuads"));
assert!(
!sent
.iter()
.any(|m| m == "Input.dispatchMouseEvent" || m == "Runtime.evaluate"),
"the native path must inject nothing and dispatch nothing: {sent:?}"
);
// The page was told to expect a navigation, and told to stop afterwards.
assert_eq!(sent.first().map(String::as_str), Some("Page.enable"));
assert_eq!(sent.last().map(String::as_str), Some("Page.disable"));
let wire = serde_json::to_value(&clicked).unwrap();
assert_eq!(wire["match"]["backendNodeId"], 7);
assert_eq!(wire["engine"], "wayfern");
assert_eq!(wire["navigated"], false);
// The fallback engine: the locator is applied by a script and the click
// is a trusted mouse event at the element's centre, exactly what the
// selector tools have always done.
let (target, frames) = fake_browser(Fake::Cooperative).await;
let ctx = agent_context_for("151.0.7922.76", target);
assert_eq!(ctx.engine, Engine::Fallback);
let clicked = agent_click_locator(&ctx, &request).await.unwrap();
assert_eq!(clicked.engine, Engine::Fallback);
assert_eq!(clicked.matched.signature, "s7");
let sent = methods(&frames);
assert!(sent.iter().any(|m| m == "Runtime.evaluate"));
let mouse: Vec<serde_json::Value> = frames
.lock()
.unwrap()
.iter()
.filter(|f| f["method"] == "Input.dispatchMouseEvent")
.cloned()
.collect();
assert_eq!(mouse.len(), 3, "move, press, release: {sent:?}");
assert_eq!(mouse[0]["params"]["type"], "mouseMoved");
assert_eq!(mouse[1]["params"]["type"], "mousePressed");
assert_eq!(mouse[2]["params"]["type"], "mouseReleased");
assert_eq!(mouse[1]["params"]["x"], 140.0);
assert_eq!(mouse[1]["params"]["button"], "left");
assert!(
!sent
.iter()
.any(|m| m.starts_with("Vellum.") || m.starts_with("Wayfern.")),
"an older browser must never be sent the domains it lacks: {sent:?}"
);
}
#[tokio::test]
async fn a_152_profile_types_through_inscribe_and_an_older_one_through_key_events() {
use crate::wayfern_cdp::test_support::{fake_browser, methods, Fake};
let request: AgentTypeRequest = serde_json::from_value(serde_json::json!({
"locator": { "role": "textbox", "name": "Email" },
"text": "hi",
"clear_first": true
}))
.unwrap();
let (target, frames) = fake_browser(Fake::Cooperative).await;
let ctx = agent_context_for("152.0.7977.64", target);
let typed = agent_type_locator(&ctx, &request, 300.0).await.unwrap();
assert!(typed.typed);
assert_eq!(typed.engine, Engine::Wayfern);
assert_eq!(typed.characters, 2);
assert_eq!(typed.corrections, Some(1));
let sent = methods(&frames);
let native: Vec<&str> = sent
.iter()
.filter(|m| {
m.starts_with("Vellum.") || m.starts_with("DOM.resolveNode") || m.starts_with("Runtime.")
})
.map(String::as_str)
.collect();
// Focus by a real strike, THEN the field is emptied on the resolved node,
// then the keys: clearing before the click would leave the caret wherever
// the click landed in a field that is no longer empty.
assert_eq!(
native,
vec![
"Vellum.acquire",
"Vellum.glide",
"Vellum.strike",
"DOM.resolveNode",
"Runtime.callFunctionOn",
"Vellum.inscribe",
"Vellum.release"
]
);
{
let sent_frames = frames.lock().unwrap();
let inscribe = sent_frames
.iter()
.find(|f| f["method"] == "Vellum.inscribe")
.unwrap();
assert_eq!(inscribe["params"]["text"], "hi");
assert_eq!(inscribe["params"]["typos"], true);
let clear = sent_frames
.iter()
.find(|f| f["method"] == "Runtime.callFunctionOn")
.unwrap();
assert_eq!(clear["params"]["arguments"][0]["value"], true);
assert_eq!(clear["params"]["objectId"], "obj-7");
}
let (target, frames) = fake_browser(Fake::Cooperative).await;
let ctx = agent_context_for("151.0.7922.76", target);
let typed = agent_type_locator(&ctx, &request, 300.0).await.unwrap();
assert_eq!(typed.engine, Engine::Fallback);
assert_eq!(typed.characters, 2);
assert_eq!(typed.corrections, None);
let sent = methods(&frames);
assert_eq!(sent.first().map(String::as_str), Some("Runtime.evaluate"));
assert!(
sent
.iter()
.filter(|m| *m == "Input.dispatchKeyEvent")
.count()
>= 4,
"two characters are at least two key downs and two key ups: {sent:?}"
);
assert!(!sent.iter().any(|m| m.starts_with("Vellum.")));
}
#[tokio::test]
async fn the_152_only_tools_refuse_an_older_profile_before_touching_the_browser() {
use crate::wayfern_cdp::test_support::{fake_browser, methods, Fake};
let (target, frames) = fake_browser(Fake::Cooperative).await;
let ctx = agent_context_for("151.0.7922.76", target);
let extraction: ExtractionRequest = serde_json::from_value(serde_json::json!({
"container": { "role": "listitem" },
"field_map": [{ "key": "title", "locator": { "role": "link" }, "source": "text" }]
}))
.unwrap();
let refused = agent_extract(&ctx, &extraction)
.await
.expect_err("extraction has no fallback");
assert!(
matches!(&refused, AgentError::RequiresWayfern152 { version } if version == "151.0.7922.76")
);
let error = refused.into_mcp();
assert_eq!(error.code, -32000);
assert!(error.message.contains("Wayfern 152"));
assert_eq!(error.data.as_ref().unwrap()["code"], "WAYFERN_152_REQUIRED");
let refused = agent_pick_element(&ctx, 5_000)
.await
.expect_err("the picker has no fallback");
assert!(matches!(refused, AgentError::RequiresWayfern152 { .. }));
assert!(
methods(&frames).is_empty(),
"a refusal for the browser's version must not open a socket to it"
);
// And a request that cannot mean anything is refused on either engine,
// before the version is even consulted.
let malformed: ExtractionRequest = serde_json::from_value(serde_json::json!({
"container": { "role": "listitem" },
"field_map": [{ "key": "price", "locator": { "role": "cell" }, "source": "attribute" }]
}))
.unwrap();
let ctx = agent_context_for(
"152.0.7977.64",
CdpTarget::Local {
ws_url: "ws://127.0.0.1:1/never".to_string(),
},
);
let refused = agent_extract(&ctx, &malformed)
.await
.expect_err("no attribute named");
assert!(
matches!(refused, AgentError::InvalidArgument(_)),
"{refused:?}"
);
assert_eq!(refused.into_mcp().code, -32602);
}
#[tokio::test]
async fn an_ambiguous_locator_carries_its_candidates_in_the_error_data() {
use crate::wayfern_cdp::test_support::{fake_browser, Fake};
let request: AgentResolveRequest = serde_json::from_value(serde_json::json!({
"locator": { "role": "button", "name": "Save" },
"candidate_limit": 5
}))
.unwrap();
for version in ["152.0.7977.64", "151.0.7922.76"] {
let (target, _) = fake_browser(Fake::AmbiguousLocator).await;
let ctx = agent_context_for(version, target);
let refused = agent_resolve_locator(&ctx, &request)
.await
.expect_err("two matches must be refused on both engines");
let error = refused.into_mcp();
assert_eq!(error.code, -32000, "{version}");
assert!(
error
.message
.starts_with("Ambiguous locator: 2 nodes match"),
"{version}: {}",
error.message
);
let data = error.data.expect("the candidates travel in data");
assert_eq!(data["code"], "LOCATOR_AMBIGUOUS");
assert_eq!(data["matchCount"], 2);
assert_eq!(
data["candidates"].as_array().map(Vec::len),
Some(2),
"{version}"
);
assert_eq!(data["candidates"][0]["backendNodeId"], 7);
}
// A clean resolution on both engines carries the engine that answered.
for (version, engine) in [("152.0.7977.64", "wayfern"), ("151.0.7922.76", "fallback")] {
let (target, _) = fake_browser(Fake::Cooperative).await;
let ctx = agent_context_for(version, target);
let resolved = agent_resolve_locator(&ctx, &request).await.unwrap();
let wire = serde_json::to_value(&resolved).unwrap();
assert_eq!(wire["engine"], engine);
assert_eq!(wire["matchCount"], 1);
assert_eq!(wire["match"]["signature"], "s7");
assert_eq!(wire["locator"]["name"], "Save");
}
// An empty locator matches everything, which is never what was meant.
let empty: AgentResolveRequest =
serde_json::from_value(serde_json::json!({ "locator": {} })).unwrap();
let (target, frames) = fake_browser(Fake::Cooperative).await;
let ctx = agent_context_for("152.0.7977.64", target);
let refused = agent_resolve_locator(&ctx, &empty)
.await
.expect_err("empty");
assert!(matches!(refused, AgentError::InvalidArgument(_)));
assert!(crate::wayfern_cdp::test_support::methods(&frames).is_empty());
}
#[tokio::test]
async fn perception_keeps_the_browsers_shape_on_both_engines() {
use crate::wayfern_cdp::test_support::{fake_browser, methods, Fake};
let request = PerceptionRequest::default();
let (target, frames) = fake_browser(Fake::Cooperative).await;
let ctx = agent_context_for("152.0.7977.64", target);
let page = agent_perceive(&ctx, &request).await.unwrap();
assert_eq!(page.engine, Engine::Wayfern);
assert_eq!(page.nodes[0].name.as_deref(), Some("Save"));
assert_eq!(methods(&frames), vec!["Wayfern.capturePagePerception"]);
let (target, frames) = fake_browser(Fake::Cooperative).await;
let ctx = agent_context_for("151.0.7922.76", target);
let page = agent_perceive(&ctx, &request).await.unwrap();
assert_eq!(page.engine, Engine::Fallback);
assert!(page.snapshot_id.starts_with("fallback-"));
assert_eq!(page.nodes[0].name.as_deref(), Some("Save"));
assert_eq!(page.frames[0].frame_id, "f0");
assert!(page.cursor.is_none());
assert_eq!(methods(&frames), vec!["Runtime.evaluate"]);
let wire = serde_json::to_value(&page).unwrap();
assert_eq!(wire["engine"], "fallback");
assert_eq!(wire["nodes"][0]["inViewport"], true);
assert_eq!(wire["stats"]["returnedNodes"], 1);
// A cursor is a 152 feature: the fallback cannot continue anything.
let continuation: PerceptionRequest =
serde_json::from_value(serde_json::json!({ "cursor": "snap-1.2" })).unwrap();
let refused = agent_perceive(&ctx, &continuation)
.await
.expect_err("no cursors");
assert!(matches!(refused, AgentError::InvalidArgument(_)));
}
/// The `backendNodeId` every frame of `method` named.
fn node_ids_sent(frames: &crate::wayfern_cdp::test_support::Frames, method: &str) -> Vec<i64> {
frames
.lock()
.unwrap()
.iter()
.filter(|f| f["method"] == method)
.filter_map(|f| f["params"]["backendNodeId"].as_i64())
.collect()
}
#[tokio::test]
async fn a_renumbered_node_id_is_found_in_the_page_before_the_click() {
use crate::wayfern_cdp::test_support::{fake_browser, methods, Fake};
// Wayfern 152 to 155 answer the right match with the wrong node id. The
// click must land on the node the page finds, never on the one the id names.
let request: AgentClickRequest = serde_json::from_value(serde_json::json!({
"locator": { "role": "button", "name": "Save" }
}))
.unwrap();
// A sibling with the same markup and other text is not the match either.
let (target, frames) = fake_browser(Fake::RenumberedToSibling).await;
let ctx = agent_context_for("154.0.8037.57", target);
let clicked = agent_click_locator(&ctx, &request).await.unwrap();
assert_eq!(clicked.matched.backend_node_id, Some(353));
assert_eq!(node_ids_sent(&frames, "DOM.getContentQuads"), vec![353]);
let (target, frames) = fake_browser(Fake::RenumberedNodeIds).await;
let ctx = agent_context_for("154.0.8037.57", target);
let clicked = agent_click_locator(&ctx, &request).await.unwrap();
assert_eq!(clicked.engine, Engine::Wayfern);
assert_eq!(clicked.matched.signature, "s7");
assert_eq!(clicked.matched.backend_node_id, Some(353));
assert_eq!(node_ids_sent(&frames, "DOM.getContentQuads"), vec![353]);
assert_eq!(
node_ids_sent(&frames, "DOM.scrollIntoViewIfNeeded"),
vec![353]
);
{
let sent = frames.lock().unwrap();
let pin = sent
.iter()
.find(|f| f["method"] == "Runtime.evaluate")
.expect("the page is asked for the match");
assert_eq!(pin["params"]["returnByValue"], false);
let expression = pin["params"]["expression"].as_str().unwrap();
assert!(expression.contains("\"mode\":\"pin\""));
assert!(expression.contains("\"signature\":\"s7\""));
}
let sent = methods(&frames);
assert!(sent.iter().any(|m| m == "Runtime.releaseObjectGroup"));
assert!(sent.iter().any(|m| m == "Vellum.strike"));
assert!(!sent.iter().any(|m| m == "Input.dispatchMouseEvent"));
let resolve: AgentResolveRequest = serde_json::from_value(serde_json::json!({
"locator": { "role": "button", "name": "Save" }
}))
.unwrap();
let (target, _) = fake_browser(Fake::RenumberedNodeIds).await;
let ctx = agent_context_for("154.0.8037.57", target);
let resolved = agent_resolve_locator(&ctx, &resolve).await.unwrap();
let wire = serde_json::to_value(&resolved).unwrap();
assert_eq!(wire["backendNodeId"], 353);
assert_eq!(wire["match"]["backendNodeId"], 353);
assert_eq!(wire["engine"], "wayfern");
}
#[tokio::test]
async fn a_locator_the_browser_cannot_match_is_applied_in_the_page() {
use crate::wayfern_cdp::test_support::{fake_browser, methods, Fake};
// The browser's index never carries `id`, so this locator can only match
// in the page.
let resolve: AgentResolveRequest = serde_json::from_value(serde_json::json!({
"locator": { "attributes": [{ "name": "id", "value": "save" }] }
}))
.unwrap();
let (target, frames) = fake_browser(Fake::NativeNoMatch).await;
let ctx = agent_context_for("154.0.8037.57", target);
let resolved = agent_resolve_locator(&ctx, &resolve).await.unwrap();
let wire = serde_json::to_value(&resolved).unwrap();
assert_eq!(wire["engine"], "fallback");
assert_eq!(wire["backendNodeId"], 353);
assert_eq!(wire["match"]["signature"], "s7");
assert_eq!(wire["locator"]["attributes"][0]["name"], "id");
let sent = methods(&frames);
assert_eq!(
sent.first().map(String::as_str),
Some("Wayfern.resolveLocator")
);
assert!(sent.iter().any(|m| m == "Runtime.getProperties"));
let click: AgentClickRequest = serde_json::from_value(serde_json::json!({
"locator": { "attributes": [{ "name": "id", "value": "save" }] }
}))
.unwrap();
let (target, frames) = fake_browser(Fake::NativeNoMatch).await;
let ctx = agent_context_for("154.0.8037.57", target);
let clicked = agent_click_locator(&ctx, &click).await.unwrap();
assert_eq!(clicked.engine, Engine::Wayfern);
assert_eq!(node_ids_sent(&frames, "DOM.getContentQuads"), vec![353]);
// Nothing in the page either: the refusal is the usual one.
let nowhere: AgentResolveRequest = serde_json::from_value(serde_json::json!({
"locator": { "role": "button", "name": "Nowhere" }
}))
.unwrap();
let (target, frames) = fake_browser(Fake::NativeNoMatch).await;
let ctx = agent_context_for("154.0.8037.57", target);
let refused = agent_resolve_locator(&ctx, &nowhere)
.await
.expect_err("nothing matches");
let error = refused.into_mcp();
assert_eq!(error.data.as_ref().unwrap()["code"], "LOCATOR_NO_MATCH");
assert!(error.message.contains("name=Nowhere"), "{}", error.message);
assert!(!methods(&frames).iter().any(|m| m == "DOM.describeNode"));
}
#[tokio::test]
async fn a_capture_that_used_its_budget_is_answered_from_the_page() {
use crate::wayfern_cdp::test_support::{fake_browser, methods, Fake};
let (target, frames) = fake_browser(Fake::SlowPerception).await;
let ctx = agent_context_for("154.0.8037.57", target);
let page = agent_perceive(&ctx, &PerceptionRequest::default())
.await
.unwrap();
assert_eq!(page.engine, Engine::Fallback);
assert_eq!(page.text, "Save");
assert_eq!(
methods(&frames),
vec!["Wayfern.capturePagePerception", "Runtime.evaluate"]
);
// A larger budget covers the same capture.
let generous: PerceptionRequest =
serde_json::from_value(serde_json::json!({ "budget_ms": 10_000 })).unwrap();
let (target, _) = fake_browser(Fake::SlowPerception).await;
let ctx = agent_context_for("154.0.8037.57", target);
let page = agent_perceive(&ctx, &generous).await.unwrap();
assert_eq!(page.engine, Engine::Wayfern);
// A continuation belongs to the browser's snapshot; the page has no cursor.
let continuation: PerceptionRequest =
serde_json::from_value(serde_json::json!({ "cursor": "snap-1.2" })).unwrap();
let (target, frames) = fake_browser(Fake::SlowPerception).await;
let ctx = agent_context_for("154.0.8037.57", target);
let page = agent_perceive(&ctx, &continuation).await.unwrap();
assert_eq!(page.engine, Engine::Wayfern);
assert_eq!(methods(&frames), vec!["Wayfern.capturePagePerception"]);
}
#[test]
fn attribute_bags_compare_what_the_index_and_the_dom_both_carry() {
let index = attribute_bag([("href", "/a"), ("data-ved", "x")].into_iter());
let dom = attribute_bag(
[
("ID", "link"),
("data-ved", "x"),
("class", "c"),
("style", "color: red"),
("HREF", "/a"),
("value", "secret"),
]
.into_iter(),
);
assert_eq!(index, dom);
assert_ne!(
index,
attribute_bag([("href", "/b"), ("data-ved", "x")].into_iter())
);
assert!(attribute_bag([("id", "x"), ("class", "y")].into_iter()).is_empty());
}
#[test]
fn the_picker_waits_less_over_mcp_than_the_relay_does() {
assert_eq!(MAX_PICK_TIMEOUT_MS, 300_000);
assert_eq!(MAX_BRIDGE_PICK_TIMEOUT_MS, 80_000);
// A wait that outlives the relay's 90 s call budget is a guaranteed failure.
const { assert!(MAX_BRIDGE_PICK_TIMEOUT_MS < 90_000) };
const { assert!(DEFAULT_PICK_TIMEOUT_MS <= MAX_BRIDGE_PICK_TIMEOUT_MS) };
}
#[test]
fn the_vellum_typing_budget_refuses_before_the_page_is_touched() {
// The browser paces the keys itself, so the refusal has to be estimated
// from the length; it must be conservative, and it must be the same
// envelope the planner answers with.
let budget = vellum_typing_budget("hello there", 300.0).expect("an ordinary field");
assert_eq!(budget, Duration::from_secs(300));
let refusal = vellum_typing_budget(&"a".repeat(2_000), 300.0).expect_err("too long");
assert_eq!(refusal.code, -32602);
let body: serde_json::Value = serde_json::from_str(&refusal.message).unwrap();
assert_eq!(body["code"], "TYPING_TOO_LONG");
assert_eq!(body["params"]["limit"], "300");
// Over the bridge the same text is refused sooner.
assert!(vellum_typing_budget(&"a".repeat(400), 300.0).is_ok());
assert!(vellum_typing_budget(&"a".repeat(400), 80.0).is_err());
// And the length bound bites first, whatever the budget.
let huge = "a".repeat(MAX_TYPING_CHARS + 1);
assert!(vellum_typing_budget(&huge, f64::MAX).is_err());
// The refusal is the structured error both doors understand.
let agent: AgentError = refusal.into();
assert!(matches!(agent, AgentError::TypingTooLong { limit, .. } if limit == 300.0));
}
#[test]
fn the_typing_tools_plan_only_for_the_engine_that_needs_a_plan() {
// The selector and index typing tools keep their whole pre-152 body, and
// the source-scanning test above pins its order. What is pinned here is
// that the 152 branch is decided from the PROFILE'S version, read at the
// point of use, and that the Vellum budget is decided in the same place as
// the plan, before the focus script.
let production = include_str!("mcp_server.rs")
.split_once("\n#[cfg(test)]")
.map_or("", |(code, _)| code);
for handler in ["handle_type_text", "handle_type_by_index"] {
let rest = production
.split(&format!("async fn {handler}("))
.nth(1)
.unwrap_or_else(|| panic!("{handler} must exist"));
let body = &rest[..rest.find("\n async fn ").unwrap_or(rest.len())];
let engine = body
.find("Engine::for_version(&profile.version)")
.unwrap_or_else(|| panic!("{handler} must read the engine off the profile"));
let budget = body
.find("vellum_typing_budget(")
.unwrap_or_else(|| panic!("{handler} must budget Vellum typing"));
assert!(
body.matches("MAX_BRIDGE_TYPING_SECONDS").count() >= 2,
"{handler} must budget both engines on the MCP budget"
);
let focus = body.find("let focus_js").unwrap();
assert!(
engine < budget && budget < focus,
"{handler}: engine, budget, then the page"
);
assert!(
body.contains("vellum_type("),
"{handler} must type through Vellum on a 152 profile"
);
assert!(
body.contains("Input.insertText"),
"{handler} must keep the instant path for every engine"
);
}
for handler in ["handle_click_element", "handle_click_by_index"] {
let rest = production
.split(&format!("async fn {handler}("))
.nth(1)
.unwrap_or_else(|| panic!("{handler} must exist"));
let body = &rest[..rest.find("\n async fn ").unwrap_or(rest.len())];
assert!(body.contains("ctx.engine.is_wayfern()"), "{handler}");
assert!(body.contains("vellum_click("), "{handler}");
assert!(
body.contains("el.click()"),
"{handler} must keep the pre-152 click"
);
}
}
#[tokio::test]
async fn every_agent_tool_is_advertised_gated_and_dispatchable() {
let server = McpServer::new();
let advertised: Vec<String> = server.get_tools().into_iter().map(|t| t.name).collect();
let production = include_str!("mcp_server.rs")
.split_once("\n#[cfg(test)]")
.map_or("", |(code, _)| code);
let dispatch = production
.split("async fn dispatch_tool_call(")
.nth(1)
.expect("dispatch must exist");
let dispatch = &dispatch[..dispatch.find("\n async fn ").unwrap_or(dispatch.len())];
for tool in [
"perceive_page",
"resolve_locator",
"click_locator",
"type_locator",
"extract_structured",
"pick_element",
"select_option",
"file_upload",
"start_network_capture",
"list_network_requests",
"get_network_request",
] {
assert!(
advertised.iter().any(|t| t == tool),
"{tool} is not advertised"
);
let arm = dispatch
.split(&format!("\"{tool}\" => {{"))
.nth(1)
.unwrap_or_else(|| panic!("{tool} is not dispatched"));
let arm = &arm[..arm.find("\n }").unwrap_or(arm.len())];
assert!(
arm.contains("can_use_browser_automation"),
"{tool} must sit behind the browser-automation gate"
);
// Every one of them takes a profile id and nothing from this disk, so
// the bridge is allowed to call them.
assert!(!LOCAL_PATH_TOOLS.contains(&tool));
assert!(!SECRET_EXPORT_TOOLS.contains(&tool));
}
// And over the bridge they are answered, not refused as local-only. They
// fail here for want of a browser, never with the local-only code.
server.mark_engine_ready_for_tests();
let call = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": { "name": "perceive_page", "arguments": { "profile_id": "00000000-0000-0000-0000-000000000000" } }
})
.to_string();
let McpOutcome::Body { body, .. } = server.handle_message(None, call.as_bytes()).await else {
panic!("expected an answer");
};
let message = body["error"]["message"].as_str().unwrap_or_default();
assert!(
!message.contains("TOOL_IS_LOCAL_ONLY"),
"the agent surface must be reachable over the bridge: {body}"
);
}
#[test]
fn aria_role_names_are_translated_to_the_browsers_tokens() {
// Wayfern indexes Blink's own role tokens, so `textbox` matches nothing
// there while `textField` does. An agent that has read ARIA writes the
// former; the translation is the client's job, and only for the tokens
// that differ in substance, since the browser already ignores case and
// separators.
let role = |name: &str| {
canonical_locator(&LocatorDescription {
role: Some(name.to_string()),
..Default::default()
})
.role
.unwrap()
};
assert_eq!(role("textbox"), "textField");
assert_eq!(role("TextBox"), "textField");
assert_eq!(role("text-box"), "textField");
assert_eq!(role("radio"), "radioButton");
assert_eq!(role("img"), "image");
assert_eq!(role("text"), "staticText");
// What the browser already understands passes through untouched.
assert_eq!(role("textField"), "textField");
assert_eq!(role("text_field"), "text_field");
assert_eq!(role("button"), "button");
assert_eq!(role("listitem"), "listitem");
// And nothing else about the locator moves.
let full = LocatorDescription {
role: Some("textbox".into()),
name: Some("Email".into()),
attributes: Some(vec![crate::wayfern_cdp::LocatorAttribute {
name: "id".into(),
value: "email".into(),
}]),
..Default::default()
};
let canonical = canonical_locator(&full);
assert_eq!(canonical.name.as_deref(), Some("Email"));
assert_eq!(canonical.attributes, full.attributes);
assert!(canonical_locator(&LocatorDescription::default()).is_empty());
}
#[tokio::test]
async fn an_agent_and_the_person_can_talk_through_the_bridge() {
let server = McpServer::new();
server.mark_engine_ready_for_tests();
let call = |session: Option<String>, body: serde_json::Value| {
let server = &server;
async move {
let bytes = body.to_string();
match server
.handle_message(session.as_deref(), bytes.as_bytes())
.await
{
McpOutcome::Body {
body,
new_session_id,
} => (body, new_session_id),
_ => panic!("expected an answer"),
}
}
};
let tool = |id: u64, name: &str, arguments: serde_json::Value| {
serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"method": "tools/call",
"params": { "name": name, "arguments": arguments }
})
};
let text = |body: &serde_json::Value, index: usize| -> serde_json::Value {
serde_json::from_str(
body["result"]["content"][index]["text"]
.as_str()
.unwrap_or("null"),
)
.unwrap_or(serde_json::Value::Null)
};
let (init, session) = call(
None,
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": { "protocolVersion": "2025-06-18", "clientInfo": { "name": "Claude Code", "version": "2.1.0" } }
}),
)
.await;
let session = session.expect("initialize opens a session");
assert!(init["result"]["instructions"]
.as_str()
.unwrap()
.contains("get_human_updates"));
let recorded = crate::agent_console::snapshot()
.await
.sessions
.into_iter()
.find(|s| s.session_id == session)
.expect("the console knows the session");
assert_eq!(recorded.client_name.as_deref(), Some("Claude Code"));
let (asked, _) = call(
Some(session.clone()),
tool(2, "ask_human", serde_json::json!({ "question": "Which account?", "choices": ["A", "B"], "wait_seconds": 0 })),
)
.await;
let pending = text(&asked, 0);
assert_eq!(pending["status"], "pending", "{asked}");
let request_id = pending["request_id"].as_u64().unwrap();
crate::agent_console::answer_agent_request(request_id, "B".to_string())
.await
.unwrap();
let (waited, _) = call(
Some(session.clone()),
tool(
3,
"wait_for_human",
serde_json::json!({ "request_id": request_id, "wait_seconds": 0 }),
),
)
.await;
assert_eq!(text(&waited, 0)["answer"], "B");
crate::agent_console::send_agent_note(
"Skip profile 7".to_string(),
Some(session.clone()),
None,
)
.await
.unwrap();
let (progress, _) = call(
Some(session.clone()),
tool(
4,
"report_progress",
serde_json::json!({ "message": "Checking", "done": 1, "total": 3 }),
),
)
.await;
let note = progress["result"]["content"][1]["text"]
.as_str()
.unwrap_or_default();
assert!(note.contains("Skip profile 7"), "{progress}");
let held = format!("held-{}", Uuid::new_v4());
let help = crate::agent_console::open_request(crate::agent_console::NewRequest {
session_id: Some(session.clone()),
kind: crate::agent_console::ThreadKind::Help,
text: "Sign in".to_string(),
profile_id: Some(held.clone()),
choices: Vec::new(),
})
.await;
let (refused, _) = call(
Some(session.clone()),
tool(
5,
"navigate",
serde_json::json!({ "profile_id": held, "url": "https://example.com" }),
),
)
.await;
assert_eq!(
refused["error"]["data"]["code"], "PROFILE_HELD_BY_HUMAN",
"{refused}"
);
let (updates, _) = call(
Some(session.clone()),
tool(6, "get_human_updates", serde_json::json!({})),
)
.await;
assert!(text(&updates, 0)["held_profiles"]
.as_array()
.unwrap()
.iter()
.any(|hold| hold["profile_id"] == held.as_str()));
crate::agent_console::dismiss_agent_request(help)
.await
.unwrap();
}
#[tokio::test]
async fn send_feedback_checks_its_arguments_and_shows_the_person_what_was_sent() {
let server = McpServer::new();
server.mark_engine_ready_for_tests();
let send = |session: &str, id: u64, arguments: serde_json::Value| {
let server = &server;
let session = session.to_string();
async move {
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"method": "tools/call",
"params": { "name": "send_feedback", "arguments": arguments }
})
.to_string();
match server.handle_message(Some(&session), body.as_bytes()).await {
McpOutcome::Body { body, .. } => body,
_ => panic!("expected an answer"),
}
}
};
let init = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": { "protocolVersion": "2025-06-18", "clientInfo": { "name": "Feedback Agent", "version": "1.2" } }
})
.to_string();
let McpOutcome::Body {
body: init,
new_session_id: Some(session),
} = server.handle_message(None, init.as_bytes()).await
else {
panic!("initialize opens a session");
};
assert!(init["result"]["instructions"]
.as_str()
.unwrap()
.contains("send_feedback"));
for (id, arguments) in [
(2, serde_json::json!({})),
(3, serde_json::json!({ "message": " " })),
(
4,
serde_json::json!({ "message": "ok", "kind": "complaint" }),
),
(
5,
serde_json::json!({ "message": "x".repeat(crate::feedback::MAX_MESSAGE_CHARS + 1) }),
),
] {
let refused = send(&session, id, arguments).await;
assert_eq!(
refused["error"]["data"]["code"], "INVALID_ARGUMENT",
"{refused}"
);
}
assert!(McpServer::is_passive_tool("send_feedback"));
let (client, calls) = crate::agent_console::feedback_context(Some(&session));
assert_eq!(client.as_deref(), Some("Feedback Agent 1.2"));
assert!(calls.iter().all(|call| call.get("arguments").is_none()));
crate::agent_console::feedback_sent(Some(&session), "bug", "navigate hangs", true);
let thread = crate::agent_console::snapshot().await.thread;
let item = thread
.iter()
.rev()
.find(|item| item.session_id.as_deref() == Some(session.as_str()))
.expect("the person sees the feedback");
assert_eq!(item.kind, crate::agent_console::ThreadKind::Feedback);
assert_eq!(item.text, "navigate hangs");
let feedback = item.feedback.as_ref().unwrap();
assert_eq!((feedback.kind.as_str(), feedback.logs), ("bug", true));
}
#[test]
fn upload_files_are_read_and_checked_before_the_page_is_touched() {
let files = upload_files(&serde_json::json!({
"files": [
{ "name": "notes.txt", "text": "héllo" },
{ "name": "../pic.png", "content_base64": "data:image/png;base64,AAEC\nAw==" },
{ "name": "raw.bin", "content_base64": "_-8" },
]
}))
.unwrap();
assert_eq!(files[0].name, "notes.txt");
assert_eq!(files[0].bytes, "héllo".as_bytes());
assert_eq!(files[1].name, "pic.png");
assert_eq!(files[1].bytes, vec![0, 1, 2, 3]);
assert_eq!(files[2].bytes, vec![0xff, 0xef]);
let refused =
|arguments: serde_json::Value| upload_files(&arguments).unwrap_err().into_mcp().message;
assert!(refused(serde_json::json!({})).contains("at least one file"));
assert!(refused(serde_json::json!({ "files": [] })).contains("at least one file"));
assert!(
refused(serde_json::json!({ "files": [{ "name": "a.txt" }] })).contains("exactly one of")
);
assert!(refused(
serde_json::json!({ "files": [{ "name": "a.txt", "text": "x", "content_base64": "eA==" }] })
)
.contains("exactly one of"));
assert!(
refused(serde_json::json!({ "files": [{ "name": "..", "text": "x" }] }))
.contains("not a usable file name")
);
assert!(refused(serde_json::json!({
"files": [{ "name": "a.txt", "text": "x" }, { "name": "A.TXT", "text": "y" }]
}))
.contains("Two files are named"));
assert!(refused(
serde_json::json!({ "files": [{ "name": "a.bin", "content_base64": "%%%" }] })
)
.contains("not base64"));
let many: Vec<serde_json::Value> = (0..=crate::agent_uploads::MAX_UPLOAD_FILES)
.map(|n| serde_json::json!({ "name": format!("{n}.txt"), "text": "x" }))
.collect();
assert!(refused(serde_json::json!({ "files": many })).contains("At most"));
let large = "x".repeat(crate::agent_uploads::MAX_UPLOAD_BYTES + 1);
assert!(
refused(serde_json::json!({ "files": [{ "name": "big.txt", "text": large }] }))
.contains("KB")
);
}
#[test]
fn a_select_refusal_tells_the_agent_what_to_do() {
let message = |answer: serde_json::Value| select_refusal(&answer).unwrap().into_mcp().message;
assert!(
message(serde_json::json!({ "error": "not_select", "tag": "div" }))
.contains("<div>, not a <select>")
);
let missing = message(serde_json::json!({
"error": "no_option",
"missing": ["Mars"],
"count": 2,
"options": [{ "value": "de", "label": "Germany", "disabled": false }],
}));
assert!(
missing.contains("Mars") && missing.contains("Germany"),
"{missing}"
);
assert!(message(serde_json::json!({ "error": "single" })).contains("takes one option"));
assert!(select_refusal(&serde_json::json!({ "selected": [], "changed": false })).is_none());
}
#[test]
fn a_form_tool_names_its_element_four_ways_and_reads_no_path() {
let server = McpServer::new();
for name in ["select_option", "file_upload"] {
let tool = server
.get_tools()
.into_iter()
.find(|tool| tool.name == name)
.unwrap();
let properties = tool.input_schema["properties"].as_object().unwrap();
for key in ["profile_id", "selector", "locator", "index", "element_id"] {
assert!(properties.contains_key(key), "{name} lacks {key}");
}
assert_eq!(tool.input_schema["required"][0], "profile_id");
assert!(!LOCAL_PATH_TOOLS.contains(&name));
}
}
#[tokio::test]
async fn a_form_tool_refuses_two_ways_of_naming_its_element() {
let server = McpServer::new();
let caller = McpCaller {
session: Some("s"),
toolsets: None,
};
for arguments in [
serde_json::json!({}),
serde_json::json!({ "selector": "#a", "index": 1 }),
serde_json::json!({ "locator": { "role": "button" }, "selector": "#a" }),
] {
let error = server
.form_element_ref(caller, "p", &arguments)
.await
.err()
.unwrap();
assert!(error.message.contains("one way"), "{arguments}");
}
let named = server
.form_element_ref(
caller,
"p",
&serde_json::json!({ "selector": "select#country" }),
)
.await
.unwrap();
assert!(matches!(named, ElementRef::Selector(selector) if selector == "select#country"));
let located = server
.form_element_ref(
caller,
"p",
&serde_json::json!({ "locator": { "role": "textbox", "name": "Email" } }),
)
.await
.unwrap();
assert!(
matches!(located, ElementRef::Locator(locator) if locator.role.as_deref() == Some("textField"))
);
}
#[test]
fn network_reads_without_a_capture_say_how_to_start_one() {
let profile = serde_json::json!({ "profile_id": "no-capture-profile" });
let error = McpServer::handle_list_network_requests(&profile).unwrap_err();
assert_eq!(error.data.unwrap()["code"], "NETWORK_CAPTURE_NOT_RUNNING");
let error = McpServer::handle_get_network_request(&serde_json::json!({
"profile_id": "no-capture-profile",
"request_id": 1,
}))
.unwrap_err();
assert!(error.message.contains("start_network_capture"));
let error = McpServer::handle_list_network_requests(&serde_json::json!({
"profile_id": "no-capture-profile",
"limit": 0,
}))
.unwrap_err();
assert_eq!(error.code, -32602);
let stopped = McpServer::handle_stop_network_capture(&profile).unwrap();
assert_eq!(stopped["structuredContent"]["stopped"], false);
}
#[test]
fn a_create_setting_that_failed_is_reported_as_not_applied() {
let clean = created_profile_result("Shop", "p-1", &[]);
assert_eq!(
clean["content"][0]["text"],
"Profile 'Shop' created (id: p-1)"
);
assert_eq!(
clean["structuredContent"]["not_applied"],
serde_json::json!([])
);
let failed = created_profile_result(
"Shop",
"p-1",
&[
("status_id", "PROFILE_STATUS_NOT_FOUND".to_string()),
("tags", "disk full".to_string()),
],
);
let text = failed["content"][0]["text"].as_str().unwrap();
assert!(text.starts_with("Profile 'Shop' created (id: p-1)"));
assert!(text.contains("Not applied: status_id (PROFILE_STATUS_NOT_FOUND); tags (disk full)"));
assert_eq!(
failed["structuredContent"]["not_applied"],
serde_json::json!(["status_id", "tags"])
);
}
}