Skip to main content

voidcrawl_mcp/
server.rs

1//! Top-level MCP service. Owns `AppState` and the `ToolRouter`.
2//!
3//! Each tool method is a thin adapter that delegates to a free
4//! function in `crate::tools::*`; the heavy lifting lives there so
5//! this file stays focused on wire-protocol concerns.
6
7use std::sync::Arc;
8
9use rmcp::{
10    ErrorData,
11    handler::server::{
12        ServerHandler,
13        router::tool::ToolRouter,
14        wrapper::{Json, Parameters},
15    },
16    model::{CallToolResult, Implementation, ProtocolVersion, ServerCapabilities, ServerInfo},
17    tool, tool_handler, tool_router,
18};
19
20use crate::{
21    errors::map_err,
22    state::AppState,
23    tools,
24    tools::{
25        actions::{
26            AxTreeArgs, AxTreeResult, CaptureCaptchaResult, ClickArgs, ClickByRoleArgs,
27            ClickVisualCoordsArgs, DetectCaptchaResult, EvalJsArgs, EvalJsResult, ExtractArgs,
28            ExtractResult, InjectCaptchaTokenArgs, NetworkCaptureResult, OkResult,
29            SessionIdArgs as ActionSessionIdArgs, SolveCaptchaArgs, SolveCaptchaResult,
30            TeleportArgs, TitleResult, TypeTextArgs, WaitIdleArgs,
31        },
32        fetch::{FetchArgs, FetchManyArgs, FetchManyResult, FetchResult},
33        introspect::PoolStatus,
34        screenshot::ScreenshotArgs,
35        session::{
36            SessionCloseResult, SessionContentResult, SessionIdArgs, SessionNavigateArgs,
37            SessionNavigateResult, SessionOpenArgs, SessionOpenResult,
38        },
39    },
40};
41
42/// The MCP service struct. Cheap to `Arc`-share.
43#[derive(Debug)]
44pub struct VoidCrawlServer {
45    state:       Arc<AppState>,
46    #[allow(dead_code, reason = "read by the `#[tool_handler]` macro expansion")]
47    tool_router: ToolRouter<Self>,
48}
49
50impl VoidCrawlServer {
51    pub fn new(state: Arc<AppState>) -> Self {
52        Self { state, tool_router: Self::tool_router() }
53    }
54
55    pub fn state(&self) -> &AppState {
56        &self.state
57    }
58}
59
60#[tool_router]
61impl VoidCrawlServer {
62    #[tool(
63        name = "fetch",
64        description = "Fetch a URL with stealth headless Chrome and return HTML + metadata. \
65Use for single-shot scrapes; for bulk use fetch_many."
66    )]
67    pub async fn fetch(
68        &self,
69        Parameters(args): Parameters<FetchArgs>,
70    ) -> Result<Json<FetchResult>, ErrorData> {
71        tools::fetch::run(self, args).await.map(Json).map_err(map_err)
72    }
73
74    #[tool(
75        name = "fetch_many",
76        description = "Fetch many URLs in parallel over the shared browser pool. Returns \
77one entry per request in input order; per-request errors do not abort the batch. \
78Each result carries `waited_ms` (time queued for a tab), and the batch carries a \
79`pool` summary {max_tabs, submitted, queued, max_waited_ms, note} — if `queued > 0` \
80you oversubscribed the pool; cap batches at `max_tabs` (see pool_status) for full parallelism."
81    )]
82    pub async fn fetch_many(
83        &self,
84        Parameters(args): Parameters<FetchManyArgs>,
85    ) -> Result<Json<FetchManyResult>, ErrorData> {
86        Ok(Json(tools::fetch::run_many(self, args).await))
87    }
88
89    #[tool(
90        name = "screenshot",
91        description = "Load a URL in stealth headless Chrome and return a full-page PNG."
92    )]
93    pub async fn screenshot(
94        &self,
95        Parameters(args): Parameters<ScreenshotArgs>,
96    ) -> Result<CallToolResult, ErrorData> {
97        tools::screenshot::run(self, args).await
98    }
99
100    #[tool(
101        name = "session_open",
102        description = "Open a new stateful browser session with a dedicated Chrome instance. \
103Returns a session_id used by session_navigate / session_content / session_close. \
104Pass `user_data_dir` to mount a persistent profile (e.g. one already logged into LinkedIn); \
105omit it for an ephemeral cookieless profile. Set `headful=true` to bring up a visible window \
106(useful for a one-time manual login into the persistent profile)."
107    )]
108    pub async fn session_open(
109        &self,
110        Parameters(args): Parameters<SessionOpenArgs>,
111    ) -> Result<Json<SessionOpenResult>, ErrorData> {
112        tools::session::open(self, args).await.map(Json)
113    }
114
115    #[tool(
116        name = "session_navigate",
117        description = "Navigate the given session to a URL and wait for it to settle. \
118wait_for accepts 'networkidle' (default) or 'selector:<css>' (event-driven, no polling)."
119    )]
120    pub async fn session_navigate(
121        &self,
122        Parameters(args): Parameters<SessionNavigateArgs>,
123    ) -> Result<Json<SessionNavigateResult>, ErrorData> {
124        tools::session::navigate(self, args).await.map(Json)
125    }
126
127    #[tool(
128        name = "session_content",
129        description = "Return the current HTML, title, and URL of the given session's page."
130    )]
131    pub async fn session_content(
132        &self,
133        Parameters(args): Parameters<SessionIdArgs>,
134    ) -> Result<Json<SessionContentResult>, ErrorData> {
135        tools::session::content(self, args).await.map(Json)
136    }
137
138    #[tool(
139        name = "session_close",
140        description = "Close the given session: shut down its Chrome instance and free resources. \
141Always call this when you're done — otherwise the browser stays alive until the server exits."
142    )]
143    pub async fn session_close(
144        &self,
145        Parameters(args): Parameters<SessionIdArgs>,
146    ) -> Result<Json<SessionCloseResult>, ErrorData> {
147        tools::session::close(self, args).await.map(Json)
148    }
149
150    #[tool(
151        name = "pool_status",
152        description = "Report the browser pool configuration plus a live snapshot of \
153concurrency: `max_tabs`, `available` (free slots right now), `in_flight`, and \
154`sessions_open`. Read `available` before a big fan-out to size the batch."
155    )]
156    pub async fn pool_status(&self) -> Result<Json<PoolStatus>, ErrorData> {
157        tools::introspect::pool_status(self).await.map(Json).map_err(map_err)
158    }
159
160    #[tool(
161        name = "click",
162        description = "Click the first element matching a CSS selector in an open session."
163    )]
164    pub async fn click(
165        &self,
166        Parameters(args): Parameters<ClickArgs>,
167    ) -> Result<Json<OkResult>, ErrorData> {
168        tools::actions::click(self, args).await.map(Json)
169    }
170
171    #[tool(
172        name = "teleport",
173        description = "Override the session's geolocation (and optionally timezone + locale) so \
174navigator.geolocation and location-aware sites resolve to the given lat/lon — 'teleport' the \
175browser. The geolocation permission is granted automatically. Call after session_open and \
176BEFORE navigating; the override persists across navigations. For Google Maps 'near me' queries: \
177use a FRESH session per location, and navigate to the search twice (prime + read) — Maps resolves \
178location on first load and applies it on the next request."
179    )]
180    pub async fn teleport(
181        &self,
182        Parameters(args): Parameters<TeleportArgs>,
183    ) -> Result<Json<OkResult>, ErrorData> {
184        tools::actions::teleport(self, args).await.map(Json)
185    }
186
187    #[tool(
188        name = "click_visual_coords",
189        description = "Click at pixel coordinates (x, y) in CSS pixels. Use when selector-based \
190clicks fail silently (React forms that ignore dispatchEvent clicks). Coords are pre-DPR: \
191divide screenshot pixels by devicePixelRatio on HiDPI."
192    )]
193    pub async fn click_visual_coords(
194        &self,
195        Parameters(args): Parameters<ClickVisualCoordsArgs>,
196    ) -> Result<Json<OkResult>, ErrorData> {
197        tools::actions::click_visual_coords(self, args).await.map(Json)
198    }
199
200    #[tool(
201        name = "type_text",
202        description = "Type text into an input. With `selector`, focuses + types. Without, \
203dispatches keys to whatever currently has focus (pair with click_visual_coords first)."
204    )]
205    pub async fn type_text(
206        &self,
207        Parameters(args): Parameters<TypeTextArgs>,
208    ) -> Result<Json<OkResult>, ErrorData> {
209        tools::actions::type_text(self, args).await.map(Json)
210    }
211
212    #[tool(
213        name = "eval_js",
214        description = "Evaluate a JS expression in the session's page. Returns the value as JSON."
215    )]
216    pub async fn eval_js(
217        &self,
218        Parameters(args): Parameters<EvalJsArgs>,
219    ) -> Result<Json<EvalJsResult>, ErrorData> {
220        tools::actions::eval_js(self, args).await.map(Json)
221    }
222
223    #[tool(name = "title", description = "Return the current document title of the session.")]
224    pub async fn title(
225        &self,
226        Parameters(args): Parameters<ActionSessionIdArgs>,
227    ) -> Result<Json<TitleResult>, ErrorData> {
228        tools::actions::title(self, args).await.map(Json)
229    }
230
231    #[tool(
232        name = "extract",
233        description = "Run document.querySelectorAll(selector) and return each element's text content."
234    )]
235    pub async fn extract(
236        &self,
237        Parameters(args): Parameters<ExtractArgs>,
238    ) -> Result<Json<ExtractResult>, ErrorData> {
239        tools::actions::extract(self, args).await.map(Json)
240    }
241
242    #[tool(
243        name = "session_ax_tree",
244        description = "Return the page's accessibility (AX) tree — the semantic view assistive \
245tech sees, with implicit roles resolved, accessible names computed, and hidden nodes pruned. \
246Default `mode=compact` gives a pruned, indented role/name outline for reading; `mode=raw` gives \
247full CDP nodes. `named_count` vs `node_count` signals AX richness: when low, fall back to HTML, \
248screenshot, or CSS selectors. Complements (does not replace) the DOM/visual tools."
249    )]
250    pub async fn session_ax_tree(
251        &self,
252        Parameters(args): Parameters<AxTreeArgs>,
253    ) -> Result<Json<AxTreeResult>, ErrorData> {
254        tools::actions::ax_tree(self, args).await.map(Json)
255    }
256
257    #[tool(
258        name = "click_by_role",
259        description = "Click an element by its accessibility role + accessible name (e.g. \
260role=\"button\", name=\"Load more\") instead of a CSS selector. More durable across redesigns, \
261but flakier when names are ambiguous, localized, or duplicated — pair with session_ax_tree to \
262see available roles/names, and fall back to `click` (CSS) or `click_visual_coords` when it fails."
263    )]
264    pub async fn click_by_role(
265        &self,
266        Parameters(args): Parameters<ClickByRoleArgs>,
267    ) -> Result<Json<OkResult>, ErrorData> {
268        tools::actions::click_by_role(self, args).await.map(Json)
269    }
270
271    #[tool(
272        name = "wait_for_network_idle",
273        description = "Wait for Chrome's network-idle lifecycle event. Event-driven, no polling."
274    )]
275    pub async fn wait_for_network_idle(
276        &self,
277        Parameters(args): Parameters<WaitIdleArgs>,
278    ) -> Result<Json<OkResult>, ErrorData> {
279        tools::actions::wait_for_network_idle(self, args).await.map(Json)
280    }
281
282    #[tool(
283        name = "network_capture",
284        description = "Return the Resource Timing entries (URL, initiator type, transfer size, duration) \
285observed since the session's most recent navigation. Backed by performance.getEntriesByType('resource')."
286    )]
287    pub async fn network_capture(
288        &self,
289        Parameters(args): Parameters<ActionSessionIdArgs>,
290    ) -> Result<Json<NetworkCaptureResult>, ErrorData> {
291        tools::actions::network_capture(self, args).await.map(Json)
292    }
293
294    #[tool(
295        name = "solve_captcha",
296        description = "Click the Turnstile / reCAPTCHA-v2 / hCaptcha checkbox in an open session \
297using real CDP mouse events (not JS click — widgets detect that) and wait for the response \
298token to appear. Returns the kind detected, the coordinates clicked, the token value (once \
299the widget writes it into its hidden input), and a `solved` flag. No-op (solved=true) when \
300the page has no captcha. Only handles widgets whose anchor frame is already visible — if \
301detect_captcha reports `turnstile` because the runtime loaded but no widget mounted, trigger \
302the form submit that mounts the widget first."
303    )]
304    pub async fn solve_captcha(
305        &self,
306        Parameters(args): Parameters<SolveCaptchaArgs>,
307    ) -> Result<Json<SolveCaptchaResult>, ErrorData> {
308        tools::actions::solve_captcha(self, args).await.map(Json)
309    }
310
311    #[tool(
312        name = "detect_captcha",
313        description = "Probe the DOM for captcha / bot-wall markers. Returns the kind tag \
314(recaptcha, hcaptcha, turnstile, cloudflare_challenge, datadome) or null."
315    )]
316    pub async fn detect_captcha(
317        &self,
318        Parameters(args): Parameters<ActionSessionIdArgs>,
319    ) -> Result<Json<DetectCaptchaResult>, ErrorData> {
320        tools::actions::detect_captcha_tool(self, args).await.map(Json)
321    }
322
323    #[tool(
324        name = "capture_captcha",
325        description = "Deep structured probe of a captcha challenge. Returns kind, sitekey, \
326widget rect + selector, response-field selector, existing token (if already solved), page URL, \
327and Turnstile action/cdata attrs. Use this to hand off to a third-party solver API \
328(2Captcha / CapSolver / Anti-Captcha) or a human-in-the-loop flow, then call \
329`inject_captcha_token` with the resulting token."
330    )]
331    pub async fn capture_captcha(
332        &self,
333        Parameters(args): Parameters<ActionSessionIdArgs>,
334    ) -> Result<Json<CaptureCaptchaResult>, ErrorData> {
335        tools::actions::capture_captcha_tool(self, args).await.map(Json)
336    }
337
338    #[tool(
339        name = "inject_captcha_token",
340        description = "Write a solved captcha token into the page's hidden response field and \
341fire input/change events so React-controlled forms pick it up. For Turnstile, invokes any \
342registered `data-callback` function. `kind` defaults to whatever is currently detected; pass \
343explicitly ('turnstile'/'recaptcha'/'hcaptcha') to skip re-detection."
344    )]
345    pub async fn inject_captcha_token(
346        &self,
347        Parameters(args): Parameters<InjectCaptchaTokenArgs>,
348    ) -> Result<Json<OkResult>, ErrorData> {
349        tools::actions::inject_captcha_token_tool(self, args).await.map(Json)
350    }
351}
352
353#[tool_handler]
354impl ServerHandler for VoidCrawlServer {
355    fn get_info(&self) -> ServerInfo {
356        let mut info = ServerInfo::default();
357        info.protocol_version = ProtocolVersion::default();
358        info.capabilities = ServerCapabilities::builder().enable_tools().build();
359        info.server_info = {
360            let mut imp = Implementation::default();
361            imp.name = "voidcrawl-mcp".into();
362            imp.version = env!("CARGO_PKG_VERSION").into();
363            imp
364        };
365        // Shipped to EVERY MCP client on connect (Claude, opencode, Codex,
366        // Cursor, Cline, Zed, …), so the AX-first workflow + gotchas reach
367        // hosts that have no skill-file mechanism. Keep this condensed; the
368        // full guide is .claude/skills/voidcrawl/SKILL.md.
369        info.instructions = Some(
370            "Stealthy headless Chrome over a shared, fingerprint-patched tab pool — a drop-in \
371replacement for Playwright / Chromium MCP.\n\n\
372WORKFLOW. Stateless scrape: `fetch` (one URL) or `fetch_many` (parallel; returns \
373{results:[{ok,result,error}]} in input order — per-item errors don't abort the batch, and \
374status_code is nested under each item's `result`). Stateful flows (login, pagination, clicking): \
375`session_open` → `session_navigate` → … → `session_close`. ALWAYS session_close; sessions are \
376cookie-isolated.\n\n\
377PERCEIVE → ACT → EXTRACT. To see a page, call `session_ax_tree` — a compact role/name outline, \
378far cheaper than HTML (don't dump raw HTML to reason over a page). If `named_count` is low vs \
379`node_count` the accessibility tree is thin; fall back to `screenshot`. To click: `click` (CSS \
380selector) or `click_by_role` (accessibility role + accessible name — durable across redesigns); \
381last resort `click_visual_coords` for React forms that ignore synthetic clicks. To extract data, \
382run `extract` / `eval_js` with a JS expression and return data, not markup.\n\n\
383GOTCHAS. `click_by_role` name matching is EXACT (case + whitespace) — read the exact name from \
384`session_ax_tree` first; use `nth` for duplicates. After an in-page (SPA) click, \
385`wait_for_network_idle` may run to its full timeout — pass a short `timeout_secs` or use \
386`wait_for:\"selector:<css>\"`. On a captcha error, surface it and rotate proxy/profile; don't \
387retry the same URL and don't try to solve."
388                .into(),
389        );
390        info
391    }
392}