use arc_swap::ArcSwap;
use base64::Engine;
use dashmap::DashMap;
use ferridriver::Page;
use ferridriver::actions;
use ferridriver::backend::BackendKind;
use ferridriver::backend::{AnyElement, AnyPage};
use ferridriver::snapshot;
use ferridriver::state::{BrowserState, ConnectMode, ContextLogHandles};
use rmcp::{
ErrorData, RoleServer, ServerHandler,
handler::server::router::tool::ToolRouter,
model::{
Annotated, CallToolResult, Content, GetPromptRequestParams, GetPromptResult, ListPromptsResult,
ListResourcesResult, PaginatedRequestParams, Prompt, PromptArgument, PromptMessage, PromptMessageRole, RawResource,
ReadResourceRequestParams, ReadResourceResult, Resource, ResourceContents, ServerCapabilities, ServerInfo,
SetLevelRequestParams,
},
service::RequestContext,
tool_handler,
};
use rustc_hash::FxHashMap;
use std::sync::Arc;
use tokio::sync::{Mutex, RwLock};
#[derive(Clone)]
pub struct SharedState {
inner: Arc<RwLock<BrowserState>>,
ref_maps: Arc<DashMap<String, RefMapHandle>>,
log_handles: Arc<DashMap<String, ContextLogHandles>>,
context_locks: Arc<DashMap<String, Arc<Mutex<()>>>>,
}
type RefMapHandle = Arc<ArcSwap<FxHashMap<String, i64>>>;
impl SharedState {
fn new(browser_state: BrowserState) -> Self {
Self {
inner: Arc::new(RwLock::new(browser_state)),
ref_maps: Arc::new(DashMap::new()),
log_handles: Arc::new(DashMap::new()),
context_locks: Arc::new(DashMap::new()),
}
}
pub(crate) async fn write(&self) -> tokio::sync::RwLockWriteGuard<'_, BrowserState> {
self.inner.write().await
}
pub(crate) async fn read(&self) -> tokio::sync::RwLockReadGuard<'_, BrowserState> {
self.inner.read().await
}
pub(crate) async fn ref_map_for(&self, context: &str) -> FxHashMap<String, i64> {
if let Some(entry) = self.ref_maps.get(context) {
return (**entry.value().load()).clone();
}
let state = self.inner.read().await;
if let Some(handle) = state.ref_map_handle(context) {
let cloned = (**handle.load()).clone();
drop(state);
self.ref_maps.insert(context.to_string(), handle);
cloned
} else {
FxHashMap::default()
}
}
pub(crate) async fn ref_map_handle(&self, context: &str) -> Option<RefMapHandle> {
if let Some(entry) = self.ref_maps.get(context) {
return Some(Arc::clone(entry.value()));
}
let state = self.inner.read().await;
let handle = state.ref_map_handle(context)?;
drop(state);
self.ref_maps.insert(context.to_string(), Arc::clone(&handle));
Some(handle)
}
pub(crate) async fn log_handles_for(&self, context: &str) -> Option<ContextLogHandles> {
if let Some(entry) = self.log_handles.get(context) {
return Some(entry.value().clone());
}
let state = self.inner.read().await;
let handles = state.log_handles(context)?;
drop(state);
self.log_handles.insert(context.to_string(), handles.clone());
Some(handles)
}
pub(crate) fn invalidate_context(&self, context: &str) {
self.ref_maps.remove(context);
self.log_handles.remove(context);
}
pub(crate) fn invalidate_all(&self) {
self.ref_maps.clear();
self.log_handles.clear();
}
pub(crate) fn state_arc(&self) -> Arc<RwLock<BrowserState>> {
Arc::clone(&self.inner)
}
}
pub type State = SharedState;
#[must_use]
pub fn ctx(s: Option<&String>) -> &str {
s.map_or("default", String::as_str)
}
pub use self::ctx as sess;
pub trait McpServerConfig: Send + Sync + 'static {
fn chrome_args(&self) -> Vec<String> {
Vec::new()
}
fn chrome_args_for_instance(&self, _instance: &str) -> Vec<String> {
Vec::new()
}
fn resolve_instance(&self, _instance: &str) -> Option<ConnectMode> {
None
}
fn server_name(&self) -> &str {
DEFAULT_SERVER_NAME
}
fn server_instructions(&self) -> &str {
DEFAULT_INSTRUCTIONS
}
}
pub const DEFAULT_SERVER_NAME: &str = "ferridriver";
pub const DEFAULT_INSTRUCTIONS: &str = "\
Browser automation via Chrome DevTools Protocol.\n\
\n\
== SESSION KEYS ==\n\
All tools accept an optional 'session' parameter. Format: 'instance:context'.\n\
- Instance (before ':') selects which browser process to use. Each instance can have \
its own Chrome flags, DNS rules, and profile. Examples: 'staging', 'dev', 'prod'.\n\
- Context (after ':') isolates cookies/storage within that browser. Use for multi-user \
testing. Examples: 'admin', 'user1', 'tester'.\n\
- Combined: 'staging:admin' = staging browser, admin context.\n\
- Plain name without ':' uses the default instance: 'mytest' = 'default:mytest'.\n\
- Omitted entirely: uses 'default:default'.\n\
\n\
When the server is configured with instance_args_command or per-instance chrome_args, \
each instance name maps to a separate Chrome process with its own configuration \
(DNS resolution, proxy settings, certificates, etc.).\n\
\n\
== SNAPSHOTS AND REFS ==\n\
Actions return an accessibility snapshot with [ref=eN] identifiers. \
Use these refs with click/hover/fill. Prefer snapshot over screenshot.\n\
IMPORTANT: Refs are tied to the current page snapshot. After page(select) or navigate, \
old refs are invalid -- use the new snapshot's refs. Always prefer 'ref' over CSS selectors \
in click/fill/hover since refs are resolved from the snapshot and work reliably across frames.\n\
\n\
== TAB MANAGEMENT ==\n\
To switch between browser tabs, use page(action='list') then page(action='select', page_index=N). \
Do NOT use evaluate to list or switch tabs.\n\
\n\
== BDD/GHERKIN ==\n\
Use run_step for natural-language browser actions (e.g. 'I click \"Submit\"'), \
run_scenario to execute full .feature files or inline Gherkin, and list_steps to discover \
available step patterns. BDD steps run on the same live session as other tools.";
pub struct DefaultConfig;
impl McpServerConfig for DefaultConfig {}
#[derive(Clone)]
pub struct McpServer {
pub(crate) state: SharedState,
pub tool_router: ToolRouter<Self>,
pub config: Arc<dyn McpServerConfig>,
extensions: Arc<dyn std::any::Any + Send + Sync>,
pub(crate) step_registry: Arc<ferridriver_bdd::registry::StepRegistry>,
pub(crate) bdd_executor: ferridriver_bdd::executor::ScenarioExecutor,
pub(crate) backend_kind: BackendKind,
}
impl std::fmt::Debug for McpServer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("McpServer").finish()
}
}
struct NoExtensions;
impl McpServer {
#[must_use]
pub fn new(mode: ConnectMode, backend: BackendKind) -> Self {
Self::with_options(mode, backend, false, Arc::new(DefaultConfig))
}
#[must_use]
pub fn new_headless(mode: ConnectMode, backend: BackendKind, headless: bool) -> Self {
Self::with_options(mode, backend, headless, Arc::new(DefaultConfig))
}
pub fn with_config(mode: ConnectMode, backend: BackendKind, config: Arc<dyn McpServerConfig>) -> Self {
Self::with_options(mode, backend, false, config)
}
pub fn with_options(
mode: ConnectMode,
backend: BackendKind,
headless: bool,
config: Arc<dyn McpServerConfig>,
) -> Self {
let mut browser_state = BrowserState::new(mode, backend);
browser_state.headless = headless;
browser_state.extra_args = config.chrome_args();
let config_clone = Arc::clone(&config);
browser_state.set_instance_args_fn(Box::new(move |instance| {
config_clone.chrome_args_for_instance(instance)
}));
let config_clone = Arc::clone(&config);
browser_state.set_instance_resolver_fn(Box::new(move |instance| config_clone.resolve_instance(instance)));
let state = SharedState::new(browser_state);
let step_registry = Arc::new(ferridriver_bdd::registry::StepRegistry::build());
let bdd_executor = ferridriver_bdd::executor::ScenarioExecutor::new(
Arc::clone(&step_registry),
std::time::Duration::from_secs(30),
false, true, );
Self {
state,
tool_router: Self::tool_router(),
config,
extensions: Arc::new(NoExtensions),
step_registry,
bdd_executor,
backend_kind: backend,
}
}
#[must_use]
pub fn with_extra_tools(mut self, extra: ToolRouter<Self>) -> Self {
self.tool_router += extra;
self
}
#[must_use]
pub fn with_extension<T: Send + Sync + 'static>(mut self, ext: Arc<T>) -> Self {
self.extensions = ext;
self
}
#[must_use]
pub fn extension<T: Send + Sync + 'static>(&self) -> Option<&T> {
self.extensions.downcast_ref::<T>()
}
pub fn err(msg: impl Into<String>) -> ErrorData {
ErrorData::internal_error(msg.into(), None)
}
pub async fn context_guard(&self, context: &str) -> tokio::sync::OwnedMutexGuard<()> {
let lock = self
.state
.context_locks
.entry(context.to_string())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone();
lock.lock_owned().await
}
pub async fn session_guard(&self, context: &str) -> tokio::sync::OwnedMutexGuard<()> {
self.context_guard(context).await
}
async fn ensure_active_page(&self, context: &str) -> Result<AnyPage, ErrorData> {
{
let state = self.state.read().await;
if let Ok(any_page) = state.active_page(context) {
return Ok(any_page.clone());
}
}
let key = ferridriver::state::SessionKey::parse(context);
let mut state = self.state.write().await;
Box::pin(state.ensure_instance(&key.instance))
.await
.map_err(Self::err)?;
if state.active_page(context).is_err() {
Box::pin(state.open_page_keyed(&key, "about:blank"))
.await
.map_err(Self::err)?;
}
state.active_page(context).map_err(Self::err).cloned()
}
pub async fn page(&self, context: &str) -> Result<Arc<Page>, ErrorData> {
let any_page = Box::pin(self.ensure_active_page(context)).await?;
Ok(Page::new(any_page))
}
pub async fn raw_page(&self, context: &str) -> Result<AnyPage, ErrorData> {
Box::pin(self.ensure_active_page(context)).await
}
pub async fn page_and_context(
&self,
context: &str,
) -> Result<(Arc<Page>, ferridriver::context::ContextRef), ErrorData> {
let any_page = Box::pin(self.ensure_active_page(context)).await?;
let page = Page::new(any_page);
let ctx_ref = ferridriver::context::ContextRef::new(self.state.state_arc(), context.to_string());
Ok((page, ctx_ref))
}
pub async fn fixtures_for_session(&self, context: &str) -> Result<ferridriver_test::model::TestFixtures, ErrorData> {
let (page, ctx_ref) = Box::pin(self.page_and_context(context)).await?;
let browser = ferridriver::Browser::from_shared_state(self.state.state_arc(), self.backend_kind);
let request =
ferridriver::api_request::APIRequestContext::new(ferridriver::api_request::RequestContextOptions::default());
Ok(ferridriver_test::model::TestFixtures {
browser: std::sync::Arc::new(browser),
page,
context: std::sync::Arc::new(ctx_ref),
request: std::sync::Arc::new(request),
test_info: std::sync::Arc::new(ferridriver_test::model::TestInfo::new_anonymous()),
modifiers: std::sync::Arc::new(ferridriver_test::model::TestModifiers::default()),
browser_config: ferridriver_test::config::BrowserConfig::default(),
bdd_args: None,
bdd_data_table: None,
bdd_doc_string: None,
})
}
pub async fn resolve(
page: &Page,
ref_map: &rustc_hash::FxHashMap<String, i64>,
r#ref: Option<&String>,
selector: Option<&String>,
) -> Result<AnyElement, String> {
actions::resolve_element(
page.inner(),
ref_map,
r#ref.map(String::as_str),
selector.map(String::as_str),
)
.await
}
pub async fn snap(&self, page: &Page, context: &str) -> String {
let snap_fut = page.snapshot_for_ai(snapshot::SnapshotOptions::default());
match tokio::time::timeout(std::time::Duration::from_secs(5), snap_fut).await {
Ok(Ok(result)) => {
if let Some(handle) = self.state.ref_map_handle(context).await {
handle.store(Arc::new(result.ref_map));
} else {
let state = self.state.read().await;
state.set_ref_map(context, result.ref_map);
}
result.full
},
Ok(Err(e)) => format!("\n[snapshot error: {e}]"),
Err(_) => "\n[snapshot timed out — page may be unresponsive or have a very large DOM]".to_string(),
}
}
pub async fn action_ok(&self, page: &Page, context: &str, msg: &str) -> Result<CallToolResult, ErrorData> {
let snap = self.snap(page, context).await;
Ok(CallToolResult::success(vec![Content::text(format!("{msg}\n\n{snap}"))]))
}
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for McpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(
ServerCapabilities::builder()
.enable_tools()
.enable_resources()
.enable_prompts()
.enable_logging()
.build(),
)
.with_instructions(self.config.server_instructions().to_string())
}
fn set_level(
&self,
_request: SetLevelRequestParams,
_context: RequestContext<RoleServer>,
) -> impl std::future::Future<Output = Result<(), ErrorData>> + Send + '_ {
std::future::ready(Ok(()))
}
async fn list_resources(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListResourcesResult, ErrorData> {
let state = self.state.read().await;
let contexts = state.list_contexts().await;
drop(state);
let mut resources = Vec::new();
let res = |uri: &str, name: &str, desc: &str, mime: &str| -> Resource {
Annotated::new(
RawResource {
uri: uri.into(),
name: name.into(),
title: None,
description: Some(desc.into()),
mime_type: Some(mime.into()),
size: None,
icons: None,
meta: None,
},
None,
)
};
for c in &contexts {
let s = &c.name;
let url = c.pages.iter().find(|p| p.active).map_or("", |p| p.url.as_str());
let title = c.pages.iter().find(|p| p.active).map_or("", |p| p.title.as_str());
resources.push(res(
&format!("browser://session/{s}/page-info"),
&format!("[{s}] Page Info"),
&format!("{url} -- {title}"),
"application/json",
));
resources.push(res(
&format!("browser://session/{s}/snapshot"),
&format!("[{s}] Snapshot"),
&format!("A11y tree for session '{s}'"),
"text/plain",
));
resources.push(res(
&format!("browser://session/{s}/screenshot"),
&format!("[{s}] Screenshot"),
&format!("PNG screenshot of session '{s}'"),
"image/png",
));
resources.push(res(
&format!("browser://session/{s}/console"),
&format!("[{s}] Console"),
&format!("Console messages in session '{s}'"),
"application/json",
));
resources.push(res(
&format!("browser://session/{s}/network"),
&format!("[{s}] Network"),
&format!("Network requests in session '{s}'"),
"application/json",
));
resources.push(res(
&format!("browser://session/{s}/cookies"),
&format!("[{s}] Cookies"),
&format!("Cookies in session '{s}'"),
"application/json",
));
}
let result = ListResourcesResult {
resources,
..Default::default()
};
Ok(result)
}
async fn read_resource(
&self,
request: ReadResourceRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<ReadResourceResult, ErrorData> {
let uri = &request.uri;
let (context_name, resource) = if let Some(rest) = uri.strip_prefix("browser://session/") {
let mut parts = rest.splitn(2, '/');
(
parts.next().unwrap_or("default").to_string(),
parts.next().unwrap_or("").to_string(),
)
} else if let Some(stripped) = uri.strip_prefix("browser://") {
("default".to_string(), stripped.to_string())
} else {
return Err(Self::err(format!("Unknown resource URI: {uri}")));
};
let page = Box::pin(self.page(&context_name)).await?;
match resource.as_str() {
"page-info" => {
let url = page.url().await.unwrap_or_default();
let title = page.title().await.unwrap_or_default();
let json =
serde_json::to_string_pretty(&serde_json::json!({"url": url, "title": title, "session": context_name}))
.unwrap_or_default();
Ok(ReadResourceResult::new(vec![
ResourceContents::text(json, uri).with_mime_type("application/json"),
]))
},
"console" => {
let handles = self
.state
.log_handles_for(&context_name)
.await
.ok_or_else(|| Self::err(format!("Context '{context_name}' not found")))?;
let msgs = handles.console.read().await;
let last: Vec<_> = msgs
.iter()
.rev()
.take(100)
.cloned()
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
drop(msgs);
let text = serde_json::to_string_pretty(&last).unwrap_or_default();
Ok(ReadResourceResult::new(vec![
ResourceContents::text(text, uri).with_mime_type("application/json"),
]))
},
"network" => {
let handles = self
.state
.log_handles_for(&context_name)
.await
.ok_or_else(|| Self::err(format!("Context '{context_name}' not found")))?;
let reqs = handles.network.read().await;
let last: Vec<_> = reqs
.iter()
.rev()
.take(100)
.cloned()
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
drop(reqs);
let text = serde_json::to_string_pretty(&last).unwrap_or_default();
Ok(ReadResourceResult::new(vec![
ResourceContents::text(text, uri).with_mime_type("application/json"),
]))
},
"snapshot" => {
let snap = self.snap(&page, &context_name).await;
Ok(ReadResourceResult::new(vec![
ResourceContents::text(snap, uri).with_mime_type("text/plain"),
]))
},
"screenshot" => {
let bytes = page
.screenshot(ferridriver::options::ScreenshotOptions::default())
.await
.map_err(Self::err)?;
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
Ok(ReadResourceResult::new(vec![
ResourceContents::blob(b64, uri).with_mime_type("image/png"),
]))
},
"cookies" => {
let cookies = page.inner().get_cookies().await.map_err(Self::err)?;
let list: Vec<serde_json::Value> = cookies
.iter()
.map(|c| serde_json::json!({"name": c.name, "value": c.value, "domain": c.domain}))
.collect();
let text = serde_json::to_string_pretty(&list).unwrap_or_default();
Ok(ReadResourceResult::new(vec![
ResourceContents::text(text, uri).with_mime_type("application/json"),
]))
},
_ => Err(Self::err(format!("Unknown resource: {uri}"))),
}
}
async fn list_prompts(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListPromptsResult, ErrorData> {
let prompts = vec![
Prompt::new(
"debug-page",
Some("Analyze the page for errors, broken elements, and console issues"),
Some(vec![
PromptArgument::new("url")
.with_description("URL to debug")
.with_required(false),
]),
),
Prompt::new(
"test-form",
Some("Fill and submit a form, verify the result"),
Some(vec![
PromptArgument::new("url")
.with_description("Page URL with the form")
.with_required(true),
PromptArgument::new("submit_selector")
.with_description("Submit button selector")
.with_required(false),
]),
),
Prompt::new(
"audit-accessibility",
Some("Check page accessibility using the a11y tree"),
Some(vec![
PromptArgument::new("url")
.with_description("URL to audit")
.with_required(true),
]),
),
Prompt::new(
"compare-sessions",
Some("Compare page state between two browser sessions"),
Some(vec![
PromptArgument::new("url")
.with_description("URL to compare")
.with_required(true),
PromptArgument::new("session_a")
.with_description("First session")
.with_required(true),
PromptArgument::new("session_b")
.with_description("Second session")
.with_required(true),
]),
),
];
let result = ListPromptsResult {
prompts,
..Default::default()
};
Ok(result)
}
async fn get_prompt(
&self,
request: GetPromptRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<GetPromptResult, ErrorData> {
let args = request.arguments.unwrap_or_default();
let get_arg = |key: &str| -> String { args.get(key).and_then(|v| v.as_str()).unwrap_or("").to_string() };
let url = get_arg("url");
match request.name.as_str() {
"debug-page" => {
let nav = if url.is_empty() {
String::new()
} else {
format!("First navigate to {url}.\n")
};
Ok(GetPromptResult::new(vec![PromptMessage::new_text(
PromptMessageRole::User,
format!(
"{nav}Debug the current page:\n1. Take a snapshot to understand the page structure\n2. Check console_messages for errors\n3. Check network_requests for failed requests (4xx/5xx)\n4. Report all issues found with suggested fixes"
),
)]))
},
"test-form" => {
let submit = {
let s = get_arg("submit_selector");
if s.is_empty() { "the submit button".into() } else { s }
};
Ok(GetPromptResult::new(vec![PromptMessage::new_text(
PromptMessageRole::User,
format!(
"Test the form on {url}:\n1. Navigate to the page\n2. Take a snapshot to identify form fields\n3. Fill all required fields with realistic test data\n4. Click {submit}\n5. Verify the form submitted successfully\n6. Report the result"
),
)]))
},
"audit-accessibility" => Ok(GetPromptResult::new(vec![PromptMessage::new_text(
PromptMessageRole::User,
format!(
"Audit the accessibility of {url}:\n1. Navigate to the page\n2. Take a snapshot (a11y tree)\n3. Check for: missing labels, incorrect heading hierarchy, images without alt text, interactive elements without accessible names, form inputs without labels\n4. Report issues with severity and how to fix each one"
),
)])),
"compare-sessions" => {
let sa = {
let s = get_arg("session_a");
if s.is_empty() { "userA".into() } else { s }
};
let sb = {
let s = get_arg("session_b");
if s.is_empty() { "userB".into() } else { s }
};
Ok(GetPromptResult::new(vec![PromptMessage::new_text(
PromptMessageRole::User,
format!(
"Compare {url} between two sessions:\n1. Open the page in session='{sa}' and session='{sb}'\n2. Take a snapshot of each\n3. Compare: visible content differences, available navigation, form fields, cookies\n4. Report what differs between the two sessions"
),
)]))
},
_ => Err(Self::err(format!("Unknown prompt: {}", request.name))),
}
}
}