1use arc_swap::ArcSwap;
4use base64::Engine;
5use dashmap::DashMap;
6use ferridriver::Page;
7use ferridriver::actions;
8use ferridriver::backend::BackendKind;
9use ferridriver::backend::{AnyElement, AnyPage};
10use ferridriver::snapshot;
11use ferridriver::state::{BrowserState, ConnectMode, ContextLogHandles};
12use rmcp::{
13 ErrorData, RoleServer, ServerHandler,
14 handler::server::router::tool::ToolRouter,
15 model::{
16 Annotated, CallToolResult, Content, GetPromptRequestParams, GetPromptResult, ListPromptsResult,
17 ListResourcesResult, PaginatedRequestParams, Prompt, PromptArgument, PromptMessage, PromptMessageRole, RawResource,
18 ReadResourceRequestParams, ReadResourceResult, Resource, ResourceContents, ServerCapabilities, ServerInfo,
19 SetLevelRequestParams,
20 },
21 service::RequestContext,
22 tool_handler,
23};
24use rustc_hash::FxHashMap;
25use std::sync::Arc;
26use tokio::sync::{Mutex, RwLock};
27
28#[derive(Clone)]
36pub struct SharedState {
37 inner: Arc<RwLock<BrowserState>>,
41 ref_maps: Arc<DashMap<String, RefMapHandle>>,
43 log_handles: Arc<DashMap<String, ContextLogHandles>>,
45 context_locks: Arc<DashMap<String, Arc<Mutex<()>>>>,
47}
48
49type RefMapHandle = Arc<ArcSwap<FxHashMap<String, i64>>>;
51
52impl SharedState {
53 fn new(browser_state: BrowserState) -> Self {
54 Self {
55 inner: Arc::new(RwLock::new(browser_state)),
56 ref_maps: Arc::new(DashMap::new()),
57 log_handles: Arc::new(DashMap::new()),
58 context_locks: Arc::new(DashMap::new()),
59 }
60 }
61
62 pub(crate) async fn write(&self) -> tokio::sync::RwLockWriteGuard<'_, BrowserState> {
64 self.inner.write().await
65 }
66
67 pub(crate) async fn read(&self) -> tokio::sync::RwLockReadGuard<'_, BrowserState> {
69 self.inner.read().await
70 }
71
72 pub(crate) async fn instance_generation(&self, context: &str) -> Option<u64> {
77 let key = ferridriver::state::SessionKey::parse(context);
78 self.inner.read().await.instance_generation(key.instance.as_ref())
79 }
80
81 pub(crate) async fn ref_map_handle(&self, context: &str) -> Option<RefMapHandle> {
83 if let Some(entry) = self.ref_maps.get(context) {
84 return Some(Arc::clone(entry.value()));
85 }
86 let state = self.inner.read().await;
87 let handle = state.ref_map_handle(context)?;
88 drop(state);
89 self.ref_maps.insert(context.to_string(), Arc::clone(&handle));
90 Some(handle)
91 }
92
93 pub(crate) async fn log_handles_for(&self, context: &str) -> Option<ContextLogHandles> {
95 if let Some(entry) = self.log_handles.get(context) {
96 return Some(entry.value().clone());
97 }
98 let state = self.inner.read().await;
99 let handles = state.log_handles(context)?;
100 drop(state);
101 self.log_handles.insert(context.to_string(), handles.clone());
102 Some(handles)
103 }
104
105 pub(crate) fn invalidate_context(&self, context: &str) {
107 self.ref_maps.remove(context);
108 self.log_handles.remove(context);
109 self.context_locks.remove(context);
113 }
114
115 pub(crate) fn invalidate_all(&self) {
117 self.ref_maps.clear();
118 self.log_handles.clear();
119 }
120
121 pub(crate) fn state_arc(&self) -> Arc<RwLock<BrowserState>> {
123 Arc::clone(&self.inner)
124 }
125}
126
127pub type State = SharedState;
129
130#[must_use]
132pub fn ctx(s: Option<&String>) -> &str {
133 s.map_or("default", String::as_str)
134}
135
136pub use self::ctx as sess;
138
139pub trait McpServerConfig: Send + Sync + 'static {
148 fn script_root(&self) -> std::path::PathBuf {
162 std::path::PathBuf::from(".ferridriver/scripts")
163 }
164
165 fn artifacts_root(&self) -> std::path::PathBuf {
174 std::path::PathBuf::from(".ferridriver/artifacts")
175 }
176
177 fn script_engine_config(&self) -> ferridriver_script::ScriptEngineConfig {
179 ferridriver_script::ScriptEngineConfig::default()
180 }
181
182 fn chrome_args(&self) -> Vec<String> {
187 Vec::new()
188 }
189
190 fn chrome_args_for_instance(&self, _instance: &str) -> Vec<String> {
198 Vec::new()
199 }
200
201 fn resolve_instance(&self, _instance: &str) -> Option<ConnectMode> {
214 None
215 }
216
217 fn server_name(&self) -> &str {
219 DEFAULT_SERVER_NAME
220 }
221
222 fn server_instructions(&self) -> &str {
224 DEFAULT_INSTRUCTIONS
225 }
226}
227
228pub const DEFAULT_SERVER_NAME: &str = "ferridriver";
230
231pub const DEFAULT_INSTRUCTIONS: &str = "\
233Browser automation via Chrome DevTools Protocol.\n\
234\n\
235== RECOMMENDED WORKFLOW ==\n\
2361. `navigate` or `connect` to bring up a session.\n\
2372. `snapshot` to see the page as an accessibility tree (ref=eN handles, text, roles) \
238BEFORE deciding on selectors. Cheap, fast, low token cost — always your first action.\n\
2393. Act via one of:\n\
240 a. `run_script` — sandboxed JS with full `page`, `context`, `request` globals for \
241imperative logic (loops, conditionals, try/catch, computed values, HTTP calls). \
242Pair with `args` to avoid string interpolation. This is the primary action tool.\n\
243 b. `evaluate` — single-line JS executed IN the page (DOM context). Use for \
244quick reads; use `run_script` for anything multi-step.\n\
2454. `snapshot` again to verify.\n\
246\n\
247Browser interaction flows through `run_script` bindings:\n\
248- Clicks, fills, hovers → `await page.click(sel)`, `await page.fill(sel, val)`, \
249`await page.locator(sel).hover()`.\n\
250- Locator chains → `page.getByRole('button', ...).first().click()`.\n\
251- Cookies, storage, geolocation → `await context.addCookies([...])`, \
252`await context.setGeolocation(...)`.\n\
253- Waits → `await page.waitForSelector(sel, { state, timeout })`.\n\
254- API calls → `await request.get(url)`, `await request.post(url, { json: {...} })`.\n\
255- Saving outputs (screenshots, PDFs, traces) → `await artifacts.writeBytes('page.png', \
256await page.screenshot())`. The `artifacts` global is rooted at the server's configured \
257artifacts_root (default `.ferridriver/artifacts/`) — separate from script source so outputs \
258don't pollute your tree.\n\
259\n\
260== SESSION KEYS ==\n\
261All tools accept an optional 'session' parameter. Format: 'instance:context'.\n\
262- Instance (before ':') selects which browser process. Each instance can have its own \
263Chrome flags, DNS rules, and profile. Examples: 'staging', 'dev', 'prod'.\n\
264- Context (after ':') isolates cookies/storage within that browser. Use for multi-user \
265testing. Examples: 'admin', 'user1', 'tester'.\n\
266- Combined: 'staging:admin' = staging browser, admin context.\n\
267- Plain name without ':' uses the default instance: 'mytest' = 'default:mytest'.\n\
268- Omitted entirely: uses 'default:default'.\n\
269- `run_script` `vars` persist per session: values set via `vars.set(...)` in one call \
270are visible to the next `run_script` with the same session. The `vars` global is a \
271plain string key/value store (use JSON.stringify for complex values).\n\
272\n\
273== SNAPSHOTS AND REFS ==\n\
274`snapshot` returns an accessibility tree with [ref=eN] identifiers. Refs are tied to \
275that specific snapshot — after `navigate`, `page(select)`, or any DOM mutation, old \
276refs are invalid. Re-snapshot before acting. When scripting, prefer Playwright-style \
277locators (`page.getByRole`, `page.getByText`, `page.locator(selector)`) over refs \
278— they survive re-snapshots.\n\
279\n\
280== TAB MANAGEMENT ==\n\
281`page(action='list')` lists tabs, `page(action='select', page_index=N)` switches. Do \
282not use `evaluate` or `run_script` to enumerate tabs — CDP page-target mapping is \
283only exposed via the `page` tool.\n\
284\n\
285== SCRIPTING SAFETY ==\n\
286`run_script` runs in a sandboxed QuickJS runtime: no raw filesystem access (only \
287`fs.*` scoped to script_root for source files + `artifacts.*` scoped to artifacts_root \
288for outputs), no runner-side network except via `request.*` (HttpClient), no \
289`process` / `require` / bare `import`. Caller-controlled data MUST be passed via the \
290`args` array, never interpolated into the `source` string — the engine does not protect \
291against source-level injection.";
292
293pub struct DefaultConfig;
295impl McpServerConfig for DefaultConfig {}
296
297#[derive(Clone)]
300pub struct McpServer {
301 pub(crate) state: SharedState,
302 pub tool_router: ToolRouter<Self>,
304 pub config: Arc<dyn McpServerConfig>,
306 extensions: Arc<dyn std::any::Any + Send + Sync>,
308 pub(crate) script_engine: Arc<ferridriver_script::ScriptEngine>,
310 pub(crate) script_sandbox: Option<Arc<ferridriver_script::PathSandbox>>,
313 pub(crate) artifacts_sandbox: Option<Arc<ferridriver_script::PathSandbox>>,
318 pub(crate) sessions: Arc<ferridriver_script::SessionTable>,
325 pub(crate) script_caps: ferridriver_script::ScriptCaps,
329 pub(crate) plugins: crate::plugin::PluginRegistry,
332}
333
334impl std::fmt::Debug for McpServer {
335 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
336 f.debug_struct("McpServer").finish()
337 }
338}
339
340struct NoExtensions;
342
343impl McpServer {
344 #[must_use]
346 pub fn new(mode: ConnectMode, backend: BackendKind) -> Self {
347 Self::with_options(mode, backend, false, Arc::new(DefaultConfig))
348 }
349
350 #[must_use]
352 pub fn new_headless(mode: ConnectMode, backend: BackendKind, headless: bool) -> Self {
353 Self::with_options(mode, backend, headless, Arc::new(DefaultConfig))
354 }
355
356 pub fn with_config(mode: ConnectMode, backend: BackendKind, config: Arc<dyn McpServerConfig>) -> Self {
358 Self::with_options(mode, backend, false, config)
359 }
360
361 pub fn with_options(
363 mode: ConnectMode,
364 backend: BackendKind,
365 headless: bool,
366 config: Arc<dyn McpServerConfig>,
367 ) -> Self {
368 let kind = match backend {
369 BackendKind::Bidi => ferridriver::options::BrowserKind::Firefox,
370 BackendKind::WebKit => ferridriver::options::BrowserKind::WebKit,
371 _ => ferridriver::options::BrowserKind::Chromium,
372 };
373 let mut browser_state = BrowserState::with_plan(
374 mode,
375 ferridriver::options::LaunchPlan {
376 backend,
377 kind,
378 headless,
379 args: config.chrome_args(),
380 ..Default::default()
381 },
382 );
383 let config_clone = Arc::clone(&config);
385 browser_state.set_instance_args_fn(Box::new(move |instance| {
386 config_clone.chrome_args_for_instance(instance)
387 }));
388 let config_clone = Arc::clone(&config);
390 browser_state.set_instance_resolver_fn(Box::new(move |instance| config_clone.resolve_instance(instance)));
391 let state = SharedState::new(browser_state);
392
393 let script_engine = Arc::new(ferridriver_script::ScriptEngine::new(config.script_engine_config()));
397 let sessions = Arc::new(ferridriver_script::SessionTable::new(
398 script_engine.config().max_session_vms,
399 script_engine.config().session_idle_ttl,
400 ));
401 let script_root = config.script_root();
402 let script_sandbox = match std::fs::create_dir_all(&script_root)
403 .map_err(|e| format!("{e}"))
404 .and_then(|()| ferridriver_script::PathSandbox::new(&script_root).map_err(|e| e.message.clone()))
405 {
406 Ok(sb) => Some(Arc::new(sb)),
407 Err(msg) => {
408 tracing::warn!(
409 script_root = %script_root.display(),
410 error = %msg,
411 "scripting disabled: failed to prepare script_root; run_script will return an error"
412 );
413 None
414 },
415 };
416
417 let artifacts_root = config.artifacts_root();
421 let artifacts_sandbox = match std::fs::create_dir_all(&artifacts_root)
422 .map_err(|e| format!("{e}"))
423 .and_then(|()| ferridriver_script::PathSandbox::new(&artifacts_root).map_err(|e| e.message.clone()))
424 {
425 Ok(sb) => Some(Arc::new(sb)),
426 Err(msg) => {
427 tracing::warn!(
428 artifacts_root = %artifacts_root.display(),
429 error = %msg,
430 "artifacts binding disabled: failed to prepare artifacts_root; scripts can still write via fs into script_root"
431 );
432 None
433 },
434 };
435
436 Self {
437 state,
438 tool_router: Self::tool_router(),
439 config,
440 extensions: Arc::new(NoExtensions),
441 script_engine,
442 script_sandbox,
443 artifacts_sandbox,
444 sessions,
445 script_caps: ferridriver_script::ScriptCaps::default(),
446 plugins: crate::plugin::PluginRegistry::default(),
447 }
448 }
449
450 pub async fn load_extensions(&mut self, paths: &[std::path::PathBuf]) {
459 if paths.is_empty() {
460 return;
461 }
462
463 let mut files = Vec::new();
467 for root in paths {
468 match crate::plugin::discover(root) {
469 Ok(v) => files.extend(v),
470 Err(e) => tracing::warn!(path = %root.display(), error = %e, "plugin discovery failed; skipping path"),
471 }
472 }
473 if files.is_empty() {
474 return;
475 }
476
477 let (loaded, errors) = crate::plugin::load_all(&files).await;
478 for e in errors {
479 tracing::warn!(error = %e, "plugin load failed; skipping");
480 }
481 for lp in &loaded {
482 let tool_names: Vec<&str> = lp.tools.iter().map(|t| t.name.as_str()).collect();
483 tracing::info!(path = %lp.path.display(), tools = ?tool_names, "loaded plugin file");
484 }
485
486 self.plugins = crate::plugin::PluginRegistry::new(loaded);
487 self.promote_plugins();
488 }
489
490 fn promote_plugins(&mut self) {
496 use rmcp::handler::server::router::tool::ToolRoute;
497 use rmcp::model::Tool;
498
499 let promoted: Vec<_> = self
500 .plugins
501 .promoted_tools()
502 .map(|t| {
503 let name = t.name.clone();
504 let desc = t.description.clone().unwrap_or_default();
505 let schema_value = t
506 .input_schema
507 .clone()
508 .unwrap_or_else(|| serde_json::json!({"type":"object","properties":{}}));
509 let schema_obj = match schema_value {
510 serde_json::Value::Object(m) => m,
511 _ => serde_json::Map::new(),
512 };
513 (name, desc, Arc::new(schema_obj))
514 })
515 .collect();
516
517 for (name, desc, schema_obj) in promoted {
518 if self.tool_router.has_route(&name) {
524 tracing::warn!(name = %name, "plugin tool name collides with an existing tool; not promoting");
525 continue;
526 }
527 let tool = Tool::new(name.clone(), desc, schema_obj);
528 let plugin_name = name.clone();
529
530 let route = ToolRoute::<Self>::new_dyn(tool, move |ctx| {
531 let plugin_name = plugin_name.clone();
532 Box::pin(async move {
533 let args_obj = ctx.arguments.clone().unwrap_or_default();
534 let args_value = serde_json::Value::Object(args_obj);
535 ctx.service.invoke_plugin(&plugin_name, args_value).await
536 })
537 });
538 self.tool_router.add_route(route);
539 tracing::info!(name = %name, "promoted plugin to MCP tool");
540 }
541 }
542
543 pub async fn invoke_plugin(
558 &self,
559 plugin_name: &str,
560 args_obj: serde_json::Value,
561 ) -> Result<rmcp::model::CallToolResult, ErrorData> {
562 use rmcp::model::{CallToolResult, Content};
563
564 let Some(manifest) = self.plugins.get_tool(plugin_name) else {
565 return Err(Self::err(format!("unknown plugin: {plugin_name}")));
566 };
567 if let Some(schema) = &manifest.input_schema
571 && let Err(msg) = validate_plugin_args(plugin_name, schema, &args_obj)
572 {
573 return Ok(CallToolResult::error(vec![Content::text(msg)]));
574 }
575
576 let session = args_obj
577 .get("session")
578 .and_then(|v| v.as_str())
579 .map_or_else(|| "default".into(), str::to_string);
580 let guard = self.session_guard(&session).await;
585 let context = self.mcp_run_context(&session).await?;
586
587 let name_literal = serde_json::to_string(plugin_name).unwrap_or_else(|_| "\"\"".into());
588 let source = format!("return await plugins[{name_literal}](args[0]);");
589 let args = vec![args_obj];
590
591 let result = self
592 .run_on_session_vm(
593 &session,
594 &guard,
595 &source,
596 &args,
597 ferridriver_script::RunOptions::default(),
598 context,
599 )
600 .await;
601
602 let json = serde_json::to_string_pretty(&result).map_err(|e| Self::err(format!("serialize result: {e}")))?;
603 let mut contents = vec![Content::text(json)];
604 if let ferridriver_script::Outcome::Error { ref error } = result.outcome {
610 let summary = format!("[{:?}] {} ({}ms)", error.kind, error.message, result.duration_ms);
611 contents.insert(0, Content::text(summary));
612 return Ok(CallToolResult::error(contents));
613 }
614 Ok(CallToolResult::success(contents))
615 }
616
617 pub(crate) fn plugin_bindings(&self) -> Vec<ferridriver_script::PluginBinding> {
621 self
622 .plugins
623 .files()
624 .iter()
625 .map(|f| ferridriver_script::PluginBinding {
626 bytecode: f.bytecode.clone(),
627 })
628 .collect()
629 }
630
631 pub(crate) async fn mcp_run_context(&self, session: &str) -> Result<ferridriver_script::RunContext, ErrorData> {
643 let Some(sandbox) = self.script_sandbox.clone() else {
644 return Err(Self::err(
645 "scripting is disabled: the configured script_root could not be prepared at server startup.",
646 ));
647 };
648 let (page, ctx_ref) = Box::pin(self.page_and_context(session)).await?;
649 let request = Arc::new(ferridriver::http_client::HttpClient::new(
650 ferridriver::http_client::HttpClientOptions::default(),
651 ));
652 let browser = Arc::new(ferridriver::Browser::from_shared_state(self.state.state_arc()));
653 Ok(ferridriver_script::RunContext {
654 vars: Arc::new(ferridriver_script::InMemoryVars::new()),
655 sandbox,
656 artifacts: self.artifacts_sandbox.clone(),
657 page: Some(page),
658 browser_context: Some(Arc::new(ctx_ref)),
659 request: Some(request),
660 browser: Some(browser),
661 plugins: self.plugin_bindings(),
662 trusted_modules: false,
663 host: ferridriver_script::ExtensionHost::Mcp,
664 caps: self.script_caps.clone(),
665 })
666 }
667
668 pub(crate) async fn run_on_session_vm(
682 &self,
683 session: &str,
684 _guard: &tokio::sync::OwnedMutexGuard<()>,
685 source: &str,
686 args: &[serde_json::Value],
687 options: ferridriver_script::RunOptions,
688 mut context: ferridriver_script::RunContext,
689 ) -> ferridriver_script::ScriptResult {
690 let slot = self.sessions.acquire(session);
691 let mut bs = slot.lock().await;
692 context.vars = bs.vars();
693 let epoch = self.state.instance_generation(session).await;
694 bs.run(
695 self.script_engine.config().clone(),
696 source,
697 args,
698 options,
699 context,
700 epoch,
701 )
702 .await
703 }
704
705 pub(crate) async fn run_module_on_session_vm(
709 &self,
710 session: &str,
711 _guard: &tokio::sync::OwnedMutexGuard<()>,
712 bundle: &ferridriver_script::CompiledBundle,
713 args: &[serde_json::Value],
714 options: ferridriver_script::RunOptions,
715 mut context: ferridriver_script::RunContext,
716 ) -> ferridriver_script::ScriptResult {
717 let slot = self.sessions.acquire(session);
718 let mut bs = slot.lock().await;
719 context.vars = bs.vars();
720 let epoch = self.state.instance_generation(session).await;
721 bs.run_module(
722 self.script_engine.config().clone(),
723 bundle,
724 args,
725 options,
726 context,
727 epoch,
728 )
729 .await
730 }
731
732 #[must_use]
734 pub fn with_extra_tools(mut self, extra: ToolRouter<Self>) -> Self {
735 self.tool_router += extra;
736 self
737 }
738
739 #[must_use]
743 pub fn with_script_caps(mut self, caps: ferridriver_script::ScriptCaps) -> Self {
744 self.script_caps = caps;
745 self
746 }
747
748 #[must_use]
750 pub fn with_extension<T: Send + Sync + 'static>(mut self, ext: Arc<T>) -> Self {
751 self.extensions = ext;
752 self
753 }
754
755 #[must_use]
757 pub fn extension<T: Send + Sync + 'static>(&self) -> Option<&T> {
758 self.extensions.downcast_ref::<T>()
759 }
760
761 pub fn err(msg: impl std::fmt::Display) -> ErrorData {
762 ErrorData::internal_error(msg.to_string(), None)
763 }
764
765 async fn read_network_resource(&self, context_name: &str, uri: &str) -> Result<ReadResourceResult, ErrorData> {
769 let handles = self
770 .state
771 .log_handles_for(context_name)
772 .await
773 .ok_or_else(|| Self::err(format!("Context '{context_name}' not found")))?;
774 let reqs = handles.network.read().await;
775 let last: Vec<_> = reqs
776 .iter()
777 .rev()
778 .take(100)
779 .cloned()
780 .collect::<Vec<_>>()
781 .into_iter()
782 .rev()
783 .collect();
784 drop(reqs);
785 let mut snapshots = Vec::with_capacity(last.len());
786 for r in &last {
787 snapshots.push(r.to_diagnostic_json().await);
788 }
789 let text = serde_json::to_string_pretty(&snapshots).unwrap_or_default();
790 Ok(ReadResourceResult::new(vec![
791 ResourceContents::text(text, uri.to_string()).with_mime_type("application/json"),
792 ]))
793 }
794
795 pub async fn context_guard(&self, context: &str) -> tokio::sync::OwnedMutexGuard<()> {
796 let lock = self
797 .state
798 .context_locks
799 .entry(context.to_string())
800 .or_insert_with(|| Arc::new(Mutex::new(())))
801 .clone();
802 lock.lock_owned().await
803 }
804
805 pub async fn session_guard(&self, context: &str) -> tokio::sync::OwnedMutexGuard<()> {
807 self.context_guard(context).await
808 }
809
810 async fn ensure_active_page(&self, context: &str) -> Result<AnyPage, ErrorData> {
815 {
816 let state = self.state.read().await;
817 if let Ok(any_page) = state.active_page(context) {
818 return Ok(any_page.clone());
819 }
820 }
821 let ctx_ref = ferridriver::context::ContextRef::new(self.state.state_arc(), context.to_string());
822 let page = Box::pin(ctx_ref.new_page()).await.map_err(Self::err)?;
823 self.state.invalidate_context(context);
824 Ok(page.inner().clone())
825 }
826
827 pub async fn page(&self, context: &str) -> Result<Arc<Page>, ErrorData> {
834 let any_page = Box::pin(self.ensure_active_page(context)).await?;
835 Ok(Page::new(any_page))
839 }
840
841 pub async fn raw_page(&self, context: &str) -> Result<AnyPage, ErrorData> {
848 Box::pin(self.ensure_active_page(context)).await
849 }
850
851 pub async fn page_and_context(
863 &self,
864 context: &str,
865 ) -> Result<(Arc<Page>, ferridriver::context::ContextRef), ErrorData> {
866 let any_page = Box::pin(self.ensure_active_page(context)).await?;
867 let page = Page::new(any_page);
868 let ctx_ref = ferridriver::context::ContextRef::new(self.state.state_arc(), context.to_string());
869 Ok((page, ctx_ref))
870 }
871
872 pub async fn resolve(
879 page: &Page,
880 ref_map: &rustc_hash::FxHashMap<String, i64>,
881 r#ref: Option<&String>,
882 selector: Option<&String>,
883 ) -> ferridriver::Result<AnyElement> {
884 actions::resolve_element(
885 page.inner(),
886 ref_map,
887 r#ref.map(String::as_str),
888 selector.map(String::as_str),
889 )
890 .await
891 }
892
893 pub async fn snap(&self, page: &Page, context: &str) -> String {
897 let snap_fut = page.snapshot_for_ai(snapshot::SnapshotOptions::default());
898 match tokio::time::timeout(std::time::Duration::from_secs(5), snap_fut).await {
899 Ok(Ok(result)) => {
900 if let Some(handle) = self.state.ref_map_handle(context).await {
902 handle.store(Arc::new(result.ref_map));
903 } else {
904 let state = self.state.read().await;
906 state.set_ref_map(context, result.ref_map);
907 }
908 result.full
909 },
910 Ok(Err(e)) => format!("\n[snapshot error: {e}]"),
911 Err(_) => "\n[snapshot timed out — page may be unresponsive or have a very large DOM]".to_string(),
912 }
913 }
914
915 pub async fn action_ok(&self, page: &Page, context: &str, msg: &str) -> Result<CallToolResult, ErrorData> {
922 let snap = self.snap(page, context).await;
923 Ok(CallToolResult::success(vec![Content::text(format!("{msg}\n\n{snap}"))]))
924 }
925}
926
927fn validate_plugin_args(plugin: &str, schema: &serde_json::Value, args: &serde_json::Value) -> Result<(), String> {
932 let validator =
933 jsonschema::validator_for(schema).map_err(|e| format!("plugin `{plugin}` has an invalid inputSchema: {e}"))?;
934 let mut messages: Vec<String> = validator
935 .iter_errors(args)
936 .map(|e| {
937 let path = e.instance_path.to_string();
938 if path.is_empty() {
939 e.to_string()
940 } else {
941 format!("{path}: {e}")
942 }
943 })
944 .take(20)
945 .collect();
946 if messages.is_empty() {
947 return Ok(());
948 }
949 messages.sort();
950 messages.dedup();
951 Err(format!(
952 "invalid arguments for `{plugin}` (does not match inputSchema):\n- {}",
953 messages.join("\n- ")
954 ))
955}
956
957#[tool_handler(router = self.tool_router)]
958impl ServerHandler for McpServer {
959 fn get_info(&self) -> ServerInfo {
960 ServerInfo::new(
961 ServerCapabilities::builder()
962 .enable_tools()
963 .enable_resources()
964 .enable_prompts()
965 .enable_logging()
966 .build(),
967 )
968 .with_instructions(self.config.server_instructions().to_string())
969 }
970
971 fn set_level(
972 &self,
973 _request: SetLevelRequestParams,
974 _context: RequestContext<RoleServer>,
975 ) -> impl std::future::Future<Output = Result<(), ErrorData>> + Send + '_ {
976 std::future::ready(Ok(()))
977 }
978
979 async fn list_resources(
980 &self,
981 _request: Option<PaginatedRequestParams>,
982 _context: RequestContext<RoleServer>,
983 ) -> Result<ListResourcesResult, ErrorData> {
984 let state = self.state.read().await;
985 let contexts = state.list_contexts().await;
986 drop(state);
987
988 let mut resources = Vec::new();
989 let res = |uri: &str, name: &str, desc: &str, mime: &str| -> Resource {
990 Annotated::new(
991 RawResource {
992 uri: uri.into(),
993 name: name.into(),
994 title: None,
995 description: Some(desc.into()),
996 mime_type: Some(mime.into()),
997 size: None,
998 icons: None,
999 meta: None,
1000 },
1001 None,
1002 )
1003 };
1004
1005 for c in &contexts {
1006 let s = &c.name;
1007 let url = c.pages.iter().find(|p| p.active).map_or("", |p| p.url.as_str());
1008 let title = c.pages.iter().find(|p| p.active).map_or("", |p| p.title.as_str());
1009 resources.push(res(
1010 &format!("browser://session/{s}/page-info"),
1011 &format!("[{s}] Page Info"),
1012 &format!("{url} -- {title}"),
1013 "application/json",
1014 ));
1015 resources.push(res(
1016 &format!("browser://session/{s}/snapshot"),
1017 &format!("[{s}] Snapshot"),
1018 &format!("A11y tree for session '{s}'"),
1019 "text/plain",
1020 ));
1021 resources.push(res(
1022 &format!("browser://session/{s}/screenshot"),
1023 &format!("[{s}] Screenshot"),
1024 &format!("PNG screenshot of session '{s}'"),
1025 "image/png",
1026 ));
1027 resources.push(res(
1028 &format!("browser://session/{s}/console"),
1029 &format!("[{s}] Console"),
1030 &format!("Console messages in session '{s}'"),
1031 "application/json",
1032 ));
1033 resources.push(res(
1034 &format!("browser://session/{s}/network"),
1035 &format!("[{s}] Network"),
1036 &format!("Network requests in session '{s}'"),
1037 "application/json",
1038 ));
1039 resources.push(res(
1040 &format!("browser://session/{s}/cookies"),
1041 &format!("[{s}] Cookies"),
1042 &format!("Cookies in session '{s}'"),
1043 "application/json",
1044 ));
1045 }
1046
1047 let result = ListResourcesResult {
1048 resources,
1049 ..Default::default()
1050 };
1051 Ok(result)
1052 }
1053
1054 async fn read_resource(
1055 &self,
1056 request: ReadResourceRequestParams,
1057 _context: RequestContext<RoleServer>,
1058 ) -> Result<ReadResourceResult, ErrorData> {
1059 let uri = &request.uri;
1060 let (context_name, resource) = if let Some(rest) = uri.strip_prefix("browser://session/") {
1061 let mut parts = rest.splitn(2, '/');
1062 (
1063 parts.next().unwrap_or("default").to_string(),
1064 parts.next().unwrap_or("").to_string(),
1065 )
1066 } else if let Some(stripped) = uri.strip_prefix("browser://") {
1067 ("default".to_string(), stripped.to_string())
1068 } else {
1069 return Err(Self::err(format!("Unknown resource URI: {uri}")));
1070 };
1071
1072 let page = Box::pin(self.page(&context_name)).await?;
1073
1074 match resource.as_str() {
1075 "page-info" => {
1076 let url = page.url();
1077 let title = page.title().await.unwrap_or_default();
1078 let json =
1079 serde_json::to_string_pretty(&serde_json::json!({"url": url, "title": title, "session": context_name}))
1080 .unwrap_or_default();
1081 Ok(ReadResourceResult::new(vec![
1082 ResourceContents::text(json, uri).with_mime_type("application/json"),
1083 ]))
1084 },
1085 "console" => {
1086 let handles = self
1087 .state
1088 .log_handles_for(&context_name)
1089 .await
1090 .ok_or_else(|| Self::err(format!("Context '{context_name}' not found")))?;
1091 let msgs = handles.console.read().await;
1092 let last: Vec<serde_json::Value> = msgs
1093 .iter()
1094 .rev()
1095 .take(100)
1096 .map(|m| {
1097 serde_json::json!({
1098 "type": m.type_str(),
1099 "text": m.text(),
1100 })
1101 })
1102 .collect::<Vec<_>>()
1103 .into_iter()
1104 .rev()
1105 .collect();
1106 drop(msgs);
1107 let text = serde_json::to_string_pretty(&last).unwrap_or_default();
1108 Ok(ReadResourceResult::new(vec![
1109 ResourceContents::text(text, uri).with_mime_type("application/json"),
1110 ]))
1111 },
1112 "network" => self.read_network_resource(&context_name, uri).await,
1113 "snapshot" => {
1114 let snap = self.snap(&page, &context_name).await;
1115 Ok(ReadResourceResult::new(vec![
1116 ResourceContents::text(snap, uri).with_mime_type("text/plain"),
1117 ]))
1118 },
1119 "screenshot" => {
1120 let bytes = page
1121 .screenshot(ferridriver::options::ScreenshotOptions::default())
1122 .await
1123 .map_err(Self::err)?;
1124 let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
1125 Ok(ReadResourceResult::new(vec![
1126 ResourceContents::blob(b64, uri).with_mime_type("image/png"),
1127 ]))
1128 },
1129 "cookies" => {
1130 let cookies = page.inner().get_cookies().await.map_err(Self::err)?;
1131 let list: Vec<serde_json::Value> = cookies
1132 .iter()
1133 .map(|c| serde_json::json!({"name": c.name, "value": c.value, "domain": c.domain}))
1134 .collect();
1135 let text = serde_json::to_string_pretty(&list).unwrap_or_default();
1136 Ok(ReadResourceResult::new(vec![
1137 ResourceContents::text(text, uri).with_mime_type("application/json"),
1138 ]))
1139 },
1140 _ => Err(Self::err(format!("Unknown resource: {uri}"))),
1141 }
1142 }
1143
1144 async fn list_prompts(
1145 &self,
1146 _request: Option<PaginatedRequestParams>,
1147 _context: RequestContext<RoleServer>,
1148 ) -> Result<ListPromptsResult, ErrorData> {
1149 let prompts = vec![
1150 Prompt::new(
1151 "debug-page",
1152 Some("Analyze the page for errors, broken elements, and console issues"),
1153 Some(vec![
1154 PromptArgument::new("url")
1155 .with_description("URL to debug")
1156 .with_required(false),
1157 ]),
1158 ),
1159 Prompt::new(
1160 "test-form",
1161 Some("Fill and submit a form, verify the result"),
1162 Some(vec![
1163 PromptArgument::new("url")
1164 .with_description("Page URL with the form")
1165 .with_required(true),
1166 PromptArgument::new("submit_selector")
1167 .with_description("Submit button selector")
1168 .with_required(false),
1169 ]),
1170 ),
1171 Prompt::new(
1172 "audit-accessibility",
1173 Some("Check page accessibility using the a11y tree"),
1174 Some(vec![
1175 PromptArgument::new("url")
1176 .with_description("URL to audit")
1177 .with_required(true),
1178 ]),
1179 ),
1180 Prompt::new(
1181 "compare-sessions",
1182 Some("Compare page state between two browser sessions"),
1183 Some(vec![
1184 PromptArgument::new("url")
1185 .with_description("URL to compare")
1186 .with_required(true),
1187 PromptArgument::new("session_a")
1188 .with_description("First session")
1189 .with_required(true),
1190 PromptArgument::new("session_b")
1191 .with_description("Second session")
1192 .with_required(true),
1193 ]),
1194 ),
1195 ];
1196 let result = ListPromptsResult {
1197 prompts,
1198 ..Default::default()
1199 };
1200 Ok(result)
1201 }
1202
1203 async fn get_prompt(
1204 &self,
1205 request: GetPromptRequestParams,
1206 _context: RequestContext<RoleServer>,
1207 ) -> Result<GetPromptResult, ErrorData> {
1208 let args = request.arguments.unwrap_or_default();
1209 let get_arg = |key: &str| -> String { args.get(key).and_then(|v| v.as_str()).unwrap_or("").to_string() };
1210 let url = get_arg("url");
1211
1212 match request.name.as_str() {
1213 "debug-page" => {
1214 let nav = if url.is_empty() {
1215 String::new()
1216 } else {
1217 format!("First navigate to {url}.\n")
1218 };
1219 Ok(GetPromptResult::new(vec![PromptMessage::new_text(
1220 PromptMessageRole::User,
1221 format!(
1222 "{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"
1223 ),
1224 )]))
1225 },
1226 "test-form" => {
1227 let submit = {
1228 let s = get_arg("submit_selector");
1229 if s.is_empty() { "the submit button".into() } else { s }
1230 };
1231 Ok(GetPromptResult::new(vec![PromptMessage::new_text(
1232 PromptMessageRole::User,
1233 format!(
1234 "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"
1235 ),
1236 )]))
1237 },
1238 "audit-accessibility" => Ok(GetPromptResult::new(vec![PromptMessage::new_text(
1239 PromptMessageRole::User,
1240 format!(
1241 "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"
1242 ),
1243 )])),
1244 "compare-sessions" => {
1245 let sa = {
1246 let s = get_arg("session_a");
1247 if s.is_empty() { "userA".into() } else { s }
1248 };
1249 let sb = {
1250 let s = get_arg("session_b");
1251 if s.is_empty() { "userB".into() } else { s }
1252 };
1253 Ok(GetPromptResult::new(vec![PromptMessage::new_text(
1254 PromptMessageRole::User,
1255 format!(
1256 "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"
1257 ),
1258 )]))
1259 },
1260 _ => Err(Self::err(format!("Unknown prompt: {}", request.name))),
1261 }
1262 }
1263}
1264
1265#[cfg(test)]
1266mod tests {
1267 use super::validate_plugin_args;
1268
1269 #[test]
1270 fn schema_validation_accepts_conforming_and_rejects_bad() {
1271 let schema = serde_json::json!({
1272 "type": "object",
1273 "properties": { "user": { "type": "string" }, "n": { "type": "integer" } },
1274 "required": ["user"],
1275 "additionalProperties": false
1276 });
1277
1278 assert!(validate_plugin_args("t", &schema, &serde_json::json!({ "user": "a", "n": 3 })).is_ok());
1279
1280 let missing = validate_plugin_args("t", &schema, &serde_json::json!({ "n": 3 })).unwrap_err();
1281 assert!(missing.contains("invalid arguments for `t`"), "{missing}");
1282
1283 let wrong_type = validate_plugin_args("t", &schema, &serde_json::json!({ "user": 1 })).unwrap_err();
1284 assert!(wrong_type.contains("invalid arguments for `t`"), "{wrong_type}");
1285
1286 let extra = validate_plugin_args("t", &schema, &serde_json::json!({ "user": "a", "x": 1 })).unwrap_err();
1287 assert!(extra.contains("invalid arguments for `t`"), "{extra}");
1288 }
1289
1290 #[test]
1291 fn an_invalid_schema_is_surfaced_loudly() {
1292 let bad_schema = serde_json::json!({ "type": "not-a-real-type" });
1293 let err = validate_plugin_args("t", &bad_schema, &serde_json::json!({})).unwrap_err();
1294 assert!(err.contains("invalid inputSchema"), "{err}");
1295 }
1296}