Skip to main content

ferridriver_mcp/
server.rs

1//! `McpServer` server struct and shared helpers used by all tools.
2
3use 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// ── SharedState ──────────────────────────────────────────────────────────────
29
30/// Shared state for the MCP server.
31///
32/// Hot paths (`ref_map` reads, log reads) use extracted `Arc` handles cached in
33/// `DashMap`s and bypass the `RwLock` entirely. Cold paths (instance init, page
34/// management) use the `RwLock<BrowserState>`.
35#[derive(Clone)]
36pub struct SharedState {
37  /// The underlying browser state. Write-locked only for mutations
38  /// (`ensure_instance`, `open_page`, `close_page`, `shutdown`, `connect`).
39  /// Read-locked for lookups that extract `Arc` handles.
40  inner: Arc<RwLock<BrowserState>>,
41  /// Cached `ref_map` handles per context — wait-free reads via `ArcSwap`.
42  ref_maps: Arc<DashMap<String, RefMapHandle>>,
43  /// Cached log handles per context.
44  log_handles: Arc<DashMap<String, ContextLogHandles>>,
45  /// Per-context serialization locks (replaces nested `Mutex<HashMap<..>>`).
46  context_locks: Arc<DashMap<String, Arc<Mutex<()>>>>,
47}
48
49/// Type alias for the `ArcSwap`-wrapped ref map used for wait-free reads.
50type 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  /// Write-lock the inner state (for mutations).
63  pub(crate) async fn write(&self) -> tokio::sync::RwLockWriteGuard<'_, BrowserState> {
64    self.inner.write().await
65  }
66
67  /// Read-lock the inner state (for lookups).
68  pub(crate) async fn read(&self) -> tokio::sync::RwLockReadGuard<'_, BrowserState> {
69    self.inner.read().await
70  }
71
72  /// Current generation of the browser instance backing `context`'s
73  /// session, or `None` if no such instance is live. Used to detect a
74  /// browser-session swap (relaunch/reconnect) so a stale script VM is
75  /// discarded rather than left holding handles into a dead session.
76  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  /// Get a cached `ArcSwap` handle for storing `ref_map`s (wait-free store).
82  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  /// Get cached log handles for a context (no `BrowserState` lock after first call).
94  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  /// Invalidate caches for a context (after `close_page`, new page, etc.).
106  pub(crate) fn invalidate_context(&self, context: &str) {
107    self.ref_maps.remove(context);
108    self.log_handles.remove(context);
109    // Drop the per-context serialization lock too; otherwise context_locks
110    // grows unbounded as contexts are created and destroyed. Any guard
111    // currently held keeps its Arc<Mutex> alive, so in-flight work is safe.
112    self.context_locks.remove(context);
113  }
114
115  /// Invalidate all caches (after shutdown).
116  pub(crate) fn invalidate_all(&self) {
117    self.ref_maps.clear();
118    self.log_handles.clear();
119  }
120
121  /// Get a clone of the inner `Arc<RwLock<BrowserState>>` for constructing `ContextRef`.
122  pub(crate) fn state_arc(&self) -> Arc<RwLock<BrowserState>> {
123    Arc::clone(&self.inner)
124  }
125}
126
127/// Backward-compat type alias.
128pub type State = SharedState;
129
130/// Backward-compat free function: derive context from session only.
131#[must_use]
132pub fn ctx(s: Option<&String>) -> &str {
133  s.map_or("default", String::as_str)
134}
135
136// Backward-compat alias so existing tool code keeps compiling during transition.
137pub use self::ctx as sess;
138
139// ── Configuration trait ─────────────────────────────────────────────────────
140
141/// Trait for customizing the MCP server behavior.
142///
143/// Implement this to control chrome launch args, browser instance resolution,
144/// server metadata, and pre-dispatch validation. The library stays generic --
145/// any domain-specific concepts (environments, auth, etc.) belong in the
146/// consumer's own `ServerHandler` wrapper.
147pub trait McpServerConfig: Send + Sync + 'static {
148  /// Root directory for the scripting sandbox used by `run_script`.
149  ///
150  /// All `fs` operations inside scripts (`readFile`, `writeFile`, `readdir`,
151  /// `exists`) and all dynamic `import(...)` calls are constrained to this
152  /// directory — traversal (`..`), absolute paths, and symlink escapes are
153  /// rejected. The directory is created at server startup if it does not
154  /// exist.
155  ///
156  /// Default: `./.ferridriver/scripts` relative to cwd. The dotfolder
157  /// convention avoids colliding with the common `scripts/` directory most
158  /// projects already use for build/CI tooling, and leaves room for sibling
159  /// subdirectories (`.ferridriver/artifacts`, `.ferridriver/cache`, ...)
160  /// without further namespace pollution.
161  fn script_root(&self) -> std::path::PathBuf {
162    std::path::PathBuf::from(".ferridriver/scripts")
163  }
164
165  /// Root directory for script output artifacts (screenshots, PDFs, traces,
166  /// downloaded bodies). Exposed to scripts as the `artifacts` global.
167  ///
168  /// Kept separate from `script_root` so outputs don't pollute the source
169  /// tree. Same sandbox rules apply. The directory is created at server
170  /// startup if it does not exist.
171  ///
172  /// Default: `./.ferridriver/artifacts` relative to cwd.
173  fn artifacts_root(&self) -> std::path::PathBuf {
174    std::path::PathBuf::from(".ferridriver/artifacts")
175  }
176
177  /// Engine-level defaults (timeout, memory, console limits) for `run_script`.
178  fn script_engine_config(&self) -> ferridriver_script::ScriptEngineConfig {
179    ferridriver_script::ScriptEngineConfig::default()
180  }
181
182  /// Base Chrome arguments applied to ALL browser instances.
183  ///
184  /// Called once at server construction. Override to inject flags that
185  /// apply globally (e.g. shared proxy settings).
186  fn chrome_args(&self) -> Vec<String> {
187    Vec::new()
188  }
189
190  /// Additional Chrome arguments for a specific browser instance.
191  ///
192  /// Called when launching a new Chrome process for the given instance name.
193  /// The instance name comes from the composite session key `"<instance>:<context>"`.
194  /// Override to inject per-instance flags like DNS resolver rules, cert flags.
195  ///
196  /// Default: no additional args (all instances get the same base flags).
197  fn chrome_args_for_instance(&self, _instance: &str) -> Vec<String> {
198    Vec::new()
199  }
200
201  /// Resolve how to connect to a browser instance by name.
202  ///
203  /// Called before launching a new browser. If this returns `Some(ConnectMode)`,
204  /// ferridriver connects to an existing browser instead of launching a new one.
205  ///
206  /// Use this to integrate with external browser managers:
207  /// - Read a `DevToolsActivePort` file from a known profile directory
208  /// - Query a service registry for running browser endpoints
209  /// - Connect to a browser launched by another tool with debugging enabled
210  ///
211  /// The instance name comes from the session key (e.g. `"staging"` from `"staging:admin"`).
212  /// Return `None` to fall through to the default behavior (launch a new browser).
213  fn resolve_instance(&self, _instance: &str) -> Option<ConnectMode> {
214    None
215  }
216
217  /// Server name for MCP `get_info`.
218  fn server_name(&self) -> &str {
219    DEFAULT_SERVER_NAME
220  }
221
222  /// Server instructions for MCP `get_info`.
223  fn server_instructions(&self) -> &str {
224    DEFAULT_INSTRUCTIONS
225  }
226}
227
228/// Default server name for MCP `get_info`.
229pub const DEFAULT_SERVER_NAME: &str = "ferridriver";
230
231/// Default instructions embedded in the MCP server.
232pub 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
293/// Default config for standalone ferridriver (no customization).
294pub struct DefaultConfig;
295impl McpServerConfig for DefaultConfig {}
296
297// ── McpServer ───────────────────────────────────────────────────────────────
298
299#[derive(Clone)]
300pub struct McpServer {
301  pub(crate) state: SharedState,
302  /// The composed tool router. Public so consumers can list tools or dispatch directly.
303  pub tool_router: ToolRouter<Self>,
304  /// Configuration trait object for customizing server behavior.
305  pub config: Arc<dyn McpServerConfig>,
306  /// Typed extension slot for consumer-specific state (e.g. Jira clients).
307  extensions: Arc<dyn std::any::Any + Send + Sync>,
308  /// `QuickJS` scripting engine -- fresh context per `run_script` invocation.
309  pub(crate) script_engine: Arc<ferridriver_script::ScriptEngine>,
310  /// Filesystem sandbox for scripts (`None` if the configured root could not
311  /// be created or canonicalised; `run_script` will return an error).
312  pub(crate) script_sandbox: Option<Arc<ferridriver_script::PathSandbox>>,
313  /// Filesystem sandbox for script outputs, exposed as the `artifacts`
314  /// global. `None` if the configured artifacts root could not be prepared;
315  /// in that case scripts just don't get an `artifacts` binding and must
316  /// use `fs` for output (which pollutes the script source directory).
317  pub(crate) artifacts_sandbox: Option<Arc<ferridriver_script::PathSandbox>>,
318  /// All live script sessions: one persistent `QuickJS` VM + its
319  /// session-scoped `vars` + the browser generation it was built
320  /// against, per session name, behind one lock each. Shared by
321  /// `run_script` and plugin calls so `globalThis`/`vars` persist
322  /// REPL-style; a browser relaunch under the same name discards the VM
323  /// (stale handles) but keeps `vars`.
324  pub(crate) sessions: Arc<ferridriver_script::SessionTable>,
325  /// Resolved scripting sandbox relaxations (env allow-list / node
326  /// compat). Default = locked down; set by [`McpServer::with_script_caps`]
327  /// from the operator's `[scripting]` config.
328  pub(crate) script_caps: ferridriver_script::ScriptCaps,
329  /// Plugins discovered + parsed at startup. Empty by default; populated
330  /// by [`McpServer::load_plugins`].
331  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
340/// Unit struct used as the default extensions value.
341struct NoExtensions;
342
343impl McpServer {
344  /// Create a server with default config (standalone mode).
345  #[must_use]
346  pub fn new(mode: ConnectMode, backend: BackendKind) -> Self {
347    Self::with_options(mode, backend, false, Arc::new(DefaultConfig))
348  }
349
350  /// Create a server with headless option.
351  #[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  /// Create a server with a custom config.
357  pub fn with_config(mode: ConnectMode, backend: BackendKind, config: Arc<dyn McpServerConfig>) -> Self {
358    Self::with_options(mode, backend, false, config)
359  }
360
361  /// Create a server with all options.
362  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    // Wire per-instance args callback from config trait.
384    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    // Wire per-instance connection resolver from config trait.
389    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    // Scripting engine + sandbox. The sandbox needs an existing canonical
394    // directory; we create the configured root up front and log (not panic)
395    // if initialisation fails so the rest of the server still works.
396    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    // Artifacts sandbox — separate directory for script outputs. If it
418    // fails to prepare we log and disable the `artifacts` global only;
419    // `run_script` itself keeps working.
420    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  /// Discover and load every configured extension file as MCP tools.
451  /// `paths` come from the top-level `extensions` config (resolved by
452  /// the CLI), each a `.js`/`.mjs`/`.ts`/`.mts` file or a directory.
453  ///
454  /// Failed extensions are logged and skipped -- one broken file should
455  /// not prevent the server from starting. Successfully loaded tools are
456  /// stored in `self.plugins` and become available as `run_script`
457  /// bindings (and, when `exposeAsTool`, as MCP tools).
458  pub async fn load_extensions(&mut self, paths: &[std::path::PathBuf]) {
459    if paths.is_empty() {
460      return;
461    }
462
463    // Discover every file across all configured roots, then bundle +
464    // compile + extract the whole set in ONE batch runtime (rolldown ->
465    // QuickJS bytecode; TypeScript and plugin-local imports resolved).
466    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  /// Register a dynamic tool route for each plugin manifest that declares
491  /// `exposeAsTool: true`. The tool's name, description, and `inputSchema`
492  /// come from the manifest. The dispatcher synthesises a one-line script
493  /// that awaits the matching binding (`await plugins['<name>'](args[0])`)
494  /// so the tool path and the `run_script` binding path share one handler.
495  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      // register_tool already rejects duplicate names within a load
519      // batch; this guards the remaining collision: a plugin name that
520      // shadows a built-in tool (or a name added by an earlier,
521      // separately-loaded batch). Skip + warn rather than silently
522      // letting `add_route` clobber a route.
523      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  /// Invoke a plugin by manifest name with the given argument object.
544  /// Backs both the `exposeAsTool` registration and any direct caller
545  /// that wants to dispatch a plugin without writing JS by hand.
546  ///
547  /// `args_obj` is wrapped into a single positional `args[0]` for the
548  /// underlying script run. The plugin's `session` argument (if present)
549  /// is honoured for browser context selection.
550  ///
551  /// # Errors
552  ///
553  /// Returns an [`ErrorData`] if the plugin name is unknown, scripting
554  /// is disabled (no usable script root), the underlying browser
555  /// session cannot be established, or the final result fails to
556  /// serialise.
557  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    // Enforce the declared inputSchema before doing any work (browser
568    // launch, session lock). A non-conforming call is the caller's bug,
569    // surfaced as a tool error so the model can correct and retry.
570    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    // Serialize per-session tool calls so concurrent run_script and plugin
581    // invocations on the same session don't race against each other's
582    // browser state (cookies, navigation, page identity). Matches the
583    // pattern other tool routers use.
584    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    // A promoted plugin is a first-class named tool: a handler failure
605    // must surface as an MCP error result (is_error) so the model can
606    // distinguish it from success, not a "success" carrying an error
607    // blob. (This deliberately differs from `run_script`, whose contract
608    // is "always succeed, inspect `status`".)
609    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  /// Snapshot the loaded plugin registry into the script-engine binding
618  /// shape. Shared by `run_script` and `invoke_plugin` so the mapping
619  /// lives in exactly one place.
620  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  /// Assemble the `RunContext` an MCP script/plugin call needs: live
632  /// page/context/request/browser handles for `session`, the script and
633  /// artifacts sandboxes, and the loaded plugin bytecode. Shared by
634  /// `run_script` and `invoke_plugin` so the wiring lives in one place.
635  ///
636  /// `vars` is a throwaway here: a session's `vars` is the durable tier
637  /// owned by the [`ferridriver_script::SessionTable`] entry (survives
638  /// VM rebuild + cap eviction for the session's lifetime), so
639  /// `run_on_session_vm` swaps in that store. The field stays required
640  /// because the one-shot/CLI/BDD constructors legitimately supply their
641  /// own; making it optional is a wider ripple, deliberately not done.
642  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  /// Run `source` on `session`'s persistent VM via the
669  /// [`ferridriver_script::SessionTable`], which owns VM creation,
670  /// warm-VM cap + idle-TTL reaping, browser-swap and poison rebuild.
671  ///
672  /// `_guard` is the per-context serialization lock, taken by reference
673  /// purely to make "the caller already holds the context guard" a
674  /// compile-time requirement instead of a comment.
675  ///
676  /// `context.vars` is replaced with the session's own durable store
677  /// (vars belong to the session, not the call), and the browser
678  /// instance generation is read so a relaunch under the same session
679  /// name rebuilds the VM (its `globalThis` may hold dead page handles)
680  /// while `vars` survive.
681  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  /// Like [`run_on_session_vm`], but runs a precompiled bundled ES module
706  /// (the TypeScript / `import` path) on the session VM. The run's result
707  /// is the module's `default` export.
708  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  /// Add extra tool routers (merges with built-in browser tools).
733  #[must_use]
734  pub fn with_extra_tools(mut self, extra: ToolRouter<Self>) -> Self {
735    self.tool_router += extra;
736    self
737  }
738
739  /// Set the scripting sandbox relaxations (resolved from the
740  /// operator's `[scripting]` config). Without this the sandbox stays
741  /// fully locked down (`process.env` empty).
742  #[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  /// Attach custom state accessible from tool handlers via `extension()`.
749  #[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  /// Access a typed extension stored on the server.
756  #[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  /// Build the JSON snapshot returned by the `network` MCP resource.
766  /// Extracted from `read_resource` because async lock + per-request
767  /// snapshotting pushed that handler over the line-count threshold.
768  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  // Backward-compat alias.
806  pub async fn session_guard(&self, context: &str) -> tokio::sync::OwnedMutexGuard<()> {
807    self.context_guard(context).await
808  }
809
810  /// Ensure a browser instance exists for the context and return its active `AnyPage`.
811  ///
812  /// Fast path (instance exists): shared read lock -- concurrent reads allowed.
813  /// Slow path (cold start): exclusive write lock -- only when launching a new browser.
814  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  /// Get a `Page` for a context, ensuring the required browser instance exists.
828  ///
829  /// # Errors
830  ///
831  /// Returns an error if the browser instance cannot be launched or the active page
832  /// for the given context cannot be retrieved.
833  pub async fn page(&self, context: &str) -> Result<Arc<Page>, ErrorData> {
834    let any_page = Box::pin(self.ensure_active_page(context)).await?;
835    // `Page::new` spawns the FrameAttached/Navigated/Detached listener
836    // and is sync after the eager `Page.getFrameTree` RTT was dropped
837    // (see `PERF_AUDIT` §M.4).
838    Ok(Page::new(any_page))
839  }
840
841  /// Get raw `AnyPage` (for low-level ops that Page doesn't cover yet).
842  ///
843  /// # Errors
844  ///
845  /// Returns an error if the browser instance cannot be launched or the active page
846  /// for the given context cannot be retrieved.
847  pub async fn raw_page(&self, context: &str) -> Result<AnyPage, ErrorData> {
848    Box::pin(self.ensure_active_page(context)).await
849  }
850
851  /// Get a `Page` and `ContextRef` for a session in a single operation.
852  ///
853  /// This is the primary entry point for BDD integration -- provides both
854  /// the page (for DOM interaction) and the context handle (for cookies,
855  /// permissions, etc.) on the same live MCP session.  A single
856  /// `ensure_active_page` call handles both, avoiding redundant lock
857  /// acquisitions.
858  ///
859  /// # Errors
860  ///
861  /// Returns an error if the browser instance cannot be launched or accessed.
862  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  /// Resolve ref to element -- delegates to `actions::resolve_element`.
873  ///
874  /// # Errors
875  ///
876  /// Returns an error if neither ref nor selector resolves to a valid element,
877  /// or if the underlying element lookup fails.
878  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  /// Build snapshot text and store `ref_map` for the context.
894  /// Uses a 5-second timeout to avoid hanging on unresponsive pages.
895  /// Stores the `ref_map` via wait-free `ArcSwap` — never drops updates.
896  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        // Wait-free store via cached ArcSwap handle
901        if let Some(handle) = self.state.ref_map_handle(context).await {
902          handle.store(Arc::new(result.ref_map));
903        } else {
904          // Fallback: read-lock state to store (context may not be cached yet)
905          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  /// Action result: description + auto-snapshot.
916  ///
917  /// # Errors
918  ///
919  /// Returns an `ErrorData` if snapshot acquisition fails critically
920  /// (soft failures produce inline error text instead).
921  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
927/// Validate a plugin call's arguments against the manifest `inputSchema`.
928/// The validator is compiled per call — tool invocations are not a hot
929/// path and schemas are tiny. An invalid schema is the plugin author's
930/// bug and is surfaced loudly rather than silently skipped.
931fn 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}