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};
19use void_crawl_core::{ManagedProfileDescription, ProfilePool, ResolvedProfilePool};
20
21use crate::{
22    VERSION,
23    errors::map_err,
24    state::AppState,
25    tools,
26    tools::{
27        actions::{
28            AxTreeArgs, AxTreeResult, CaptureCaptchaResult, ClickArgs, ClickByRoleArgs,
29            ClickVisualCoordsArgs, DetectCaptchaResult, EvalJsArgs, EvalJsInFrameArgs,
30            EvalJsResult, ExtractArgs, ExtractResult, InjectCaptchaTokenArgs, NetworkCaptureResult,
31            OkResult, SessionIdArgs as ActionSessionIdArgs, SolveCaptchaArgs, SolveCaptchaResult,
32            TeleportArgs, TitleResult, TypeTextArgs, WaitIdleArgs,
33        },
34        challenge::{
35            CaptureChallengeArgs, CaptureChallengeResult, MarkChallengeArgs, ResolutionResult,
36            WaitChallengeArgs, WaitChallengeResult,
37        },
38        download::{
39            DownloadArgs, DownloadArmArgs, DownloadArmResult, DownloadResult, DownloadWaitArgs,
40        },
41        fetch::{FetchArgs, FetchManyArgs, FetchManyResult, FetchResult},
42        introspect::PoolStatus,
43        profile_registry::{
44            ProfileCloneArgs, ProfileCreateArgs, ProfileDeleteArgs, ProfileDeleteResult,
45            ProfileDescribeArgs, ProfileListArgs, ProfileListResult, ProfilePoolCreateArgs,
46            ProfilePoolDescribeArgs, ProfilePoolListArgs, ProfilePoolListResult,
47        },
48        screenshot::ScreenshotArgs,
49        session::{
50            SessionCloseResult, SessionContentResult, SessionIdArgs, SessionNavigateArgs,
51            SessionNavigateResult, SessionOpenArgs, SessionOpenResult,
52        },
53        snapshot::{FetchSnapshotArgs, PageSnapshot, SessionSnapshotArgs},
54    },
55};
56
57/// The MCP service struct. Cheap to `Arc`-share.
58#[derive(Debug)]
59pub struct VoidCrawlServer {
60    state:       Arc<AppState>,
61    #[allow(dead_code, reason = "read by the `#[tool_handler]` macro expansion")]
62    tool_router: ToolRouter<Self>,
63}
64
65impl VoidCrawlServer {
66    pub fn new(state: Arc<AppState>) -> Self {
67        Self { state, tool_router: Self::tool_router() }
68    }
69
70    pub fn state(&self) -> &AppState {
71        &self.state
72    }
73}
74
75#[tool_router]
76impl VoidCrawlServer {
77    #[tool(
78        name = "fetch",
79        description = "Fetch a URL with stealth headless Chrome and return HTML + metadata. \
80Use for single-shot scrapes; for bulk use fetch_many."
81    )]
82    pub async fn fetch(
83        &self,
84        Parameters(args): Parameters<FetchArgs>,
85    ) -> Result<Json<FetchResult>, ErrorData> {
86        tools::fetch::run(self, args).await.map(Json).map_err(map_err)
87    }
88
89    #[tool(
90        name = "fetch_many",
91        description = "Fetch many URLs in parallel over the shared browser pool. Returns \
92one entry per request in input order; per-request errors do not abort the batch. \
93Each result carries `waited_ms` (time queued for a tab), and the batch carries a \
94`pool` summary {max_tabs, submitted, queued, max_waited_ms, note} — if `queued > 0` \
95you oversubscribed the pool; cap batches at `max_tabs` (see pool_status) for full parallelism."
96    )]
97    pub async fn fetch_many(
98        &self,
99        Parameters(args): Parameters<FetchManyArgs>,
100    ) -> Result<Json<FetchManyResult>, ErrorData> {
101        Ok(Json(tools::fetch::run_many(self, args).await))
102    }
103
104    #[tool(
105        name = "fetch_snapshot",
106        description = "Fetch a URL with stealth headless Chrome and return a compact rendered-page \
107snapshot: headings, text_blocks, links, controls, forms, metadata, and truncation stats. Use as \
108the first pass for large pages; use fetch only when you truly need raw HTML."
109    )]
110    pub async fn fetch_snapshot(
111        &self,
112        Parameters(args): Parameters<FetchSnapshotArgs>,
113    ) -> Result<Json<PageSnapshot>, ErrorData> {
114        tools::snapshot::fetch(self, args).await.map(Json).map_err(map_err)
115    }
116
117    #[tool(
118        name = "download",
119        description = "Download a file (PDF, archive, image, …) through stealth Chrome and scan \
120it with a built-in Rust antivirus gate (magic-byte type check + yara-x signatures) BEFORE it is \
121trusted. The file is fetched into a quarantine dir and only moved into `output_dir` if it passes \
122every check; a flagged file is deleted and the result has `ok=false` with a `reason`. Returns \
123{ok, verdict, path?, reason?, detected_mime, size}. Use this instead of `fetch` when you need the \
124actual bytes of a downloadable resource rather than rendered HTML. OPT-IN: disabled unless the \
125server is run with VOIDCRAWL_ALLOW_DOWNLOADS=1. NOTE: a `clean` verdict means it passed the \
126size + content-type + bundled-signature checks, not that it is guaranteed malware-free."
127    )]
128    pub async fn download(
129        &self,
130        Parameters(args): Parameters<DownloadArgs>,
131    ) -> Result<Json<DownloadResult>, ErrorData> {
132        tools::download::run(self, args).await.map(Json).map_err(map_err)
133    }
134
135    #[tool(
136        name = "download_arm",
137        description = "Arm an open session to capture the file produced by the NEXT \
138download-triggering action — for downloads started by clicking a button (e.g. Google Drive's \
139'Download'), where there's no stable URL to pass to `download`. Flow: session_open → \
140session_navigate → download_arm → click_by_role(\"button\",\"Download\") (+ \"Download anyway\" if \
141an interstitial appears) → download_wait. OPT-IN: needs VOIDCRAWL_ALLOW_DOWNLOADS=1."
142    )]
143    pub async fn download_arm(
144        &self,
145        Parameters(args): Parameters<DownloadArmArgs>,
146    ) -> Result<Json<DownloadArmResult>, ErrorData> {
147        tools::download::arm(self, args).await.map(Json).map_err(map_err)
148    }
149
150    #[tool(
151        name = "download_wait",
152        description = "Wait for the download armed by `download_arm` to land, scan it with the \
153antivirus gate, and (if clean) move it into the output dir. Returns {ok, verdict, path?, reason?, \
154detected_mime, size}. Call after the click(s) that trigger the download. NOTE: a `clean` verdict \
155means it passed the size + bundled-signature checks; the content-type disguise check does NOT run \
156on action downloads (no Content-Type is observed), so `clean` is not a malware-free guarantee."
157    )]
158    pub async fn download_wait(
159        &self,
160        Parameters(args): Parameters<DownloadWaitArgs>,
161    ) -> Result<Json<DownloadResult>, ErrorData> {
162        tools::download::wait(self, args).await.map(Json).map_err(map_err)
163    }
164
165    #[tool(
166        name = "screenshot",
167        description = "Load a URL in stealth headless Chrome and return a full-page PNG."
168    )]
169    pub async fn screenshot(
170        &self,
171        Parameters(args): Parameters<ScreenshotArgs>,
172    ) -> Result<CallToolResult, ErrorData> {
173        tools::screenshot::run(self, args).await
174    }
175
176    #[tool(
177        name = "profile_list",
178        description = "List VoidCrawl-managed Chromium profiles. Returns metadata only; cookies and storage values are never exposed."
179    )]
180    pub async fn profile_list(
181        &self,
182        Parameters(args): Parameters<ProfileListArgs>,
183    ) -> Result<Json<ProfileListResult>, ErrorData> {
184        tools::profile_registry::list(self, args).await.map(Json)
185    }
186
187    #[tool(
188        name = "profile_create",
189        description = "Create a standalone VoidCrawl-managed Chromium profile under the managed profile root."
190    )]
191    pub async fn profile_create(
192        &self,
193        Parameters(args): Parameters<ProfileCreateArgs>,
194    ) -> Result<Json<ManagedProfileDescription>, ErrorData> {
195        tools::profile_registry::create(self, args).await.map(Json)
196    }
197
198    #[tool(name = "profile_describe", description = "Describe one managed Chromium profile.")]
199    pub async fn profile_describe(
200        &self,
201        Parameters(args): Parameters<ProfileDescribeArgs>,
202    ) -> Result<Json<ManagedProfileDescription>, ErrorData> {
203        tools::profile_registry::describe(self, args).await.map(Json)
204    }
205
206    #[tool(
207        name = "profile_clone",
208        description = "Clone a managed profile id or source user-data-dir path into a new VoidCrawl-managed profile."
209    )]
210    pub async fn profile_clone(
211        &self,
212        Parameters(args): Parameters<ProfileCloneArgs>,
213    ) -> Result<Json<ManagedProfileDescription>, ErrorData> {
214        tools::profile_registry::clone(self, args).await.map(Json)
215    }
216
217    #[tool(
218        name = "profile_delete",
219        description = "Delete a managed profile if it is not currently leased."
220    )]
221    pub async fn profile_delete(
222        &self,
223        Parameters(args): Parameters<ProfileDeleteArgs>,
224    ) -> Result<Json<ProfileDeleteResult>, ErrorData> {
225        tools::profile_registry::delete(self, args).await.map(Json)
226    }
227
228    #[tool(name = "profile_pool_list", description = "List managed Chromium profile pools.")]
229    pub async fn profile_pool_list(
230        &self,
231        Parameters(args): Parameters<ProfilePoolListArgs>,
232    ) -> Result<Json<ProfilePoolListResult>, ErrorData> {
233        tools::profile_registry::pool_list(self, args).await.map(Json)
234    }
235
236    #[tool(
237        name = "profile_pool_create",
238        description = "Create or replace a named ordered managed-profile pool. Default max_active is 3."
239    )]
240    pub async fn profile_pool_create(
241        &self,
242        Parameters(args): Parameters<ProfilePoolCreateArgs>,
243    ) -> Result<Json<ProfilePool>, ErrorData> {
244        tools::profile_registry::pool_create(self, args).await.map(Json)
245    }
246
247    #[tool(
248        name = "profile_pool_describe",
249        description = "Describe a named managed-profile pool and its profile metadata."
250    )]
251    pub async fn profile_pool_describe(
252        &self,
253        Parameters(args): Parameters<ProfilePoolDescribeArgs>,
254    ) -> Result<Json<ResolvedProfilePool>, ErrorData> {
255        tools::profile_registry::pool_describe(self, args).await.map(Json)
256    }
257
258    #[tool(
259        name = "session_open",
260        description = "Open a new stateful browser session with a dedicated Chrome instance. \
261Returns a session_id used by session_navigate / session_content / session_close. \
262Pass `profile_id` or `profile_pool` to lease a VoidCrawl-managed profile, or `user_data_dir` \
263for an explicit path; omit all for an ephemeral cookieless profile. Set `headful=true` to bring \
264up a visible window (useful for a one-time manual login into the persistent profile)."
265    )]
266    pub async fn session_open(
267        &self,
268        Parameters(args): Parameters<SessionOpenArgs>,
269    ) -> Result<Json<SessionOpenResult>, ErrorData> {
270        tools::session::open(self, args).await.map(Json)
271    }
272
273    #[tool(
274        name = "session_navigate",
275        description = "Navigate the given session to a URL and wait for it to settle. \
276wait_for accepts 'networkidle' (default) or 'selector:<css>' (event-driven, no polling)."
277    )]
278    pub async fn session_navigate(
279        &self,
280        Parameters(args): Parameters<SessionNavigateArgs>,
281    ) -> Result<Json<SessionNavigateResult>, ErrorData> {
282        tools::session::navigate(self, args).await.map(Json)
283    }
284
285    #[tool(
286        name = "session_content",
287        description = "Return the current HTML, title, and URL of the given session's page."
288    )]
289    pub async fn session_content(
290        &self,
291        Parameters(args): Parameters<SessionIdArgs>,
292    ) -> Result<Json<SessionContentResult>, ErrorData> {
293        tools::session::content(self, args).await.map(Json)
294    }
295
296    #[tool(
297        name = "session_snapshot",
298        description = "Return a compact rendered-page snapshot for the current stateful session: \
299headings, text_blocks, links, controls, forms, metadata, and truncation stats. Use after clicking, \
300pagination, login, or other stateful flows; use session_content only when raw HTML is required."
301    )]
302    pub async fn session_snapshot(
303        &self,
304        Parameters(args): Parameters<SessionSnapshotArgs>,
305    ) -> Result<Json<PageSnapshot>, ErrorData> {
306        tools::snapshot::session(self, args).await.map(Json)
307    }
308
309    #[tool(
310        name = "session_close",
311        description = "Close the given session: shut down its Chrome instance and free resources. \
312Always call this when you're done — otherwise the browser stays alive until the server exits."
313    )]
314    pub async fn session_close(
315        &self,
316        Parameters(args): Parameters<SessionIdArgs>,
317    ) -> Result<Json<SessionCloseResult>, ErrorData> {
318        tools::session::close(self, args).await.map(Json)
319    }
320
321    #[tool(
322        name = "pool_status",
323        description = "Report the browser pool configuration plus a live snapshot of \
324concurrency: `max_tabs`, `available` (free slots right now), `in_flight`, and \
325`sessions_open`. Read `available` before a big fan-out to size the batch."
326    )]
327    pub async fn pool_status(&self) -> Result<Json<PoolStatus>, ErrorData> {
328        tools::introspect::pool_status(self).await.map(Json).map_err(map_err)
329    }
330
331    #[tool(
332        name = "click",
333        description = "Click the first element matching a CSS selector in an open session."
334    )]
335    pub async fn click(
336        &self,
337        Parameters(args): Parameters<ClickArgs>,
338    ) -> Result<Json<OkResult>, ErrorData> {
339        tools::actions::click(self, args).await.map(Json)
340    }
341
342    #[tool(
343        name = "teleport",
344        description = "Override the session's geolocation (and optionally timezone + locale) so \
345navigator.geolocation and location-aware sites resolve to the given lat/lon — 'teleport' the \
346browser. The geolocation permission is granted automatically. Call after session_open and \
347BEFORE navigating; the override persists across navigations. For Google Maps 'near me' queries: \
348use a FRESH session per location, and navigate to the search twice (prime + read) — Maps resolves \
349location on first load and applies it on the next request."
350    )]
351    pub async fn teleport(
352        &self,
353        Parameters(args): Parameters<TeleportArgs>,
354    ) -> Result<Json<OkResult>, ErrorData> {
355        tools::actions::teleport(self, args).await.map(Json)
356    }
357
358    #[tool(
359        name = "click_visual_coords",
360        description = "Click at pixel coordinates (x, y) in CSS pixels. Use when selector-based \
361clicks fail silently (React forms that ignore dispatchEvent clicks). Coords are pre-DPR: \
362divide screenshot pixels by devicePixelRatio on HiDPI."
363    )]
364    pub async fn click_visual_coords(
365        &self,
366        Parameters(args): Parameters<ClickVisualCoordsArgs>,
367    ) -> Result<Json<OkResult>, ErrorData> {
368        tools::actions::click_visual_coords(self, args).await.map(Json)
369    }
370
371    #[tool(
372        name = "type_text",
373        description = "Type text into an input. With `selector`, focuses + types. Without, \
374dispatches keys to whatever currently has focus (pair with click_visual_coords first)."
375    )]
376    pub async fn type_text(
377        &self,
378        Parameters(args): Parameters<TypeTextArgs>,
379    ) -> Result<Json<OkResult>, ErrorData> {
380        tools::actions::type_text(self, args).await.map(Json)
381    }
382
383    #[tool(
384        name = "eval_js",
385        description = "Evaluate a JS expression in the session's page. Returns the value as JSON."
386    )]
387    pub async fn eval_js(
388        &self,
389        Parameters(args): Parameters<EvalJsArgs>,
390    ) -> Result<Json<EvalJsResult>, ErrorData> {
391        tools::actions::eval_js(self, args).await.map(Json)
392    }
393
394    #[tool(
395        name = "eval_js_in_frame",
396        description = "Evaluate a JS expression inside a specific (possibly cross-origin) iframe, \
397                       selected by a substring of its URL. The expression runs in that frame's own \
398                       execution context (`document` is the frame's document) — the way to read or \
399                       drive an iframe whose `contentDocument` is null from the parent. Returns the \
400                       value as JSON."
401    )]
402    pub async fn eval_js_in_frame(
403        &self,
404        Parameters(args): Parameters<EvalJsInFrameArgs>,
405    ) -> Result<Json<EvalJsResult>, ErrorData> {
406        tools::actions::eval_js_in_frame(self, args).await.map(Json)
407    }
408
409    #[tool(name = "title", description = "Return the current document title of the session.")]
410    pub async fn title(
411        &self,
412        Parameters(args): Parameters<ActionSessionIdArgs>,
413    ) -> Result<Json<TitleResult>, ErrorData> {
414        tools::actions::title(self, args).await.map(Json)
415    }
416
417    #[tool(
418        name = "extract",
419        description = "Run document.querySelectorAll(selector) and return each element's text content."
420    )]
421    pub async fn extract(
422        &self,
423        Parameters(args): Parameters<ExtractArgs>,
424    ) -> Result<Json<ExtractResult>, ErrorData> {
425        tools::actions::extract(self, args).await.map(Json)
426    }
427
428    #[tool(
429        name = "session_ax_tree",
430        description = "Return the page's accessibility (AX) tree — the semantic view assistive \
431tech sees, with implicit roles resolved, accessible names computed, and hidden nodes pruned. \
432Default `mode=compact` gives a pruned, indented role/name outline for reading; `mode=raw` gives \
433full CDP nodes. `named_count` vs `node_count` signals AX richness: when low, fall back to HTML, \
434screenshot, or CSS selectors. Complements (does not replace) the DOM/visual tools."
435    )]
436    pub async fn session_ax_tree(
437        &self,
438        Parameters(args): Parameters<AxTreeArgs>,
439    ) -> Result<Json<AxTreeResult>, ErrorData> {
440        tools::actions::ax_tree(self, args).await.map(Json)
441    }
442
443    #[tool(
444        name = "click_by_role",
445        description = "Click an element by its accessibility role + accessible name (e.g. \
446role=\"button\", name=\"Load more\") instead of a CSS selector. More durable across redesigns, \
447but flakier when names are ambiguous, localized, or duplicated — pair with session_ax_tree to \
448see available roles/names, and fall back to `click` (CSS) or `click_visual_coords` when it fails."
449    )]
450    pub async fn click_by_role(
451        &self,
452        Parameters(args): Parameters<ClickByRoleArgs>,
453    ) -> Result<Json<OkResult>, ErrorData> {
454        tools::actions::click_by_role(self, args).await.map(Json)
455    }
456
457    #[tool(
458        name = "wait_for_network_idle",
459        description = "Wait for Chrome's network-idle lifecycle event. Event-driven, no polling."
460    )]
461    pub async fn wait_for_network_idle(
462        &self,
463        Parameters(args): Parameters<WaitIdleArgs>,
464    ) -> Result<Json<OkResult>, ErrorData> {
465        tools::actions::wait_for_network_idle(self, args).await.map(Json)
466    }
467
468    #[tool(
469        name = "network_capture",
470        description = "Return the Resource Timing entries (URL, initiator type, transfer size, duration) \
471observed since the session's most recent navigation. Backed by performance.getEntriesByType('resource')."
472    )]
473    pub async fn network_capture(
474        &self,
475        Parameters(args): Parameters<ActionSessionIdArgs>,
476    ) -> Result<Json<NetworkCaptureResult>, ErrorData> {
477        tools::actions::network_capture(self, args).await.map(Json)
478    }
479
480    #[tool(
481        name = "solve_captcha",
482        description = "Click the Turnstile / reCAPTCHA-v2 / hCaptcha checkbox in an open session \
483using real CDP mouse events (not JS click — widgets detect that) and wait for the response \
484token to appear. Returns the kind detected, the coordinates clicked, the token value (once \
485the widget writes it into its hidden input), and a `solved` flag. No-op (solved=true) when \
486the page has no captcha. Only handles widgets whose anchor frame is already visible — if \
487detect_captcha reports `turnstile` because the runtime loaded but no widget mounted, trigger \
488the form submit that mounts the widget first."
489    )]
490    pub async fn solve_captcha(
491        &self,
492        Parameters(args): Parameters<SolveCaptchaArgs>,
493    ) -> Result<Json<SolveCaptchaResult>, ErrorData> {
494        tools::actions::solve_captcha(self, args).await.map(Json)
495    }
496
497    #[tool(
498        name = "detect_captcha",
499        description = "Probe the DOM for captcha / bot-wall markers. Returns the kind tag \
500(recaptcha, hcaptcha, turnstile, cloudflare_challenge, datadome) or null."
501    )]
502    pub async fn detect_captcha(
503        &self,
504        Parameters(args): Parameters<ActionSessionIdArgs>,
505    ) -> Result<Json<DetectCaptchaResult>, ErrorData> {
506        tools::actions::detect_captcha_tool(self, args).await.map(Json)
507    }
508
509    #[tool(
510        name = "capture_captcha",
511        description = "Deep structured probe of a captcha challenge. Returns kind, sitekey, \
512widget rect + selector, response-field selector, existing token (if already solved), page URL, \
513and Turnstile action/cdata attrs. Use this to hand off to a third-party solver API \
514(2Captcha / CapSolver / Anti-Captcha) or a human-in-the-loop flow, then call \
515`inject_captcha_token` with the resulting token."
516    )]
517    pub async fn capture_captcha(
518        &self,
519        Parameters(args): Parameters<ActionSessionIdArgs>,
520    ) -> Result<Json<CaptureCaptchaResult>, ErrorData> {
521        tools::actions::capture_captcha_tool(self, args).await.map(Json)
522    }
523
524    #[tool(
525        name = "inject_captcha_token",
526        description = "Write a solved captcha token into the page's hidden response field and \
527fire input/change events so React-controlled forms pick it up. For Turnstile, invokes any \
528registered `data-callback` function. `kind` defaults to whatever is currently detected; pass \
529explicitly ('turnstile'/'recaptcha'/'hcaptcha') to skip re-detection."
530    )]
531    pub async fn inject_captcha_token(
532        &self,
533        Parameters(args): Parameters<InjectCaptchaTokenArgs>,
534    ) -> Result<Json<OkResult>, ErrorData> {
535        tools::actions::inject_captcha_token_tool(self, args).await.map(Json)
536    }
537
538    #[tool(
539        name = "capture_challenge",
540        description = "Capture the current session's active anti-bot/captcha challenge as a \
541neutral event. Combines the last session_navigate anti-bot verdict with a live DOM captcha probe, \
542and returns same-tab attach coordinates {websocket_url,target_id,session_id} plus optional vnc_url \
543and novnc_url. V1 flow: open noVNC/VNC, clear the wall manually, then call \
544mark_challenge_resolved and wait_for_challenge_resolution. Presence-only CDN signals do not create \
545a blocking event."
546    )]
547    pub async fn capture_challenge(
548        &self,
549        Parameters(args): Parameters<CaptureChallengeArgs>,
550    ) -> Result<Json<CaptureChallengeResult>, ErrorData> {
551        tools::challenge::capture(self, args).await.map(Json)
552    }
553
554    #[tool(
555        name = "mark_challenge_resolved",
556        description = "Mark a captured challenge event resolved after the operator or resolver \
557clears it in the same tab. Defaults resolver=manual_vnc; later phases can pass yosoi_recipe, \
558open_sesame_session_actor, or agent_mcp."
559    )]
560    pub async fn mark_challenge_resolved(
561        &self,
562        Parameters(args): Parameters<MarkChallengeArgs>,
563    ) -> Result<Json<ResolutionResult>, ErrorData> {
564        tools::challenge::mark_resolved(self, args).await.map(Json)
565    }
566
567    #[tool(
568        name = "mark_challenge_failed",
569        description = "Mark a captured challenge event failed. Use when manual VNC/noVNC or an \
570automated resolver cannot clear the wall; callers can then rotate identity or fail with evidence."
571    )]
572    pub async fn mark_challenge_failed(
573        &self,
574        Parameters(args): Parameters<MarkChallengeArgs>,
575    ) -> Result<Json<ResolutionResult>, ErrorData> {
576        tools::challenge::mark_failed(self, args).await.map(Json)
577    }
578
579    #[tool(
580        name = "wait_for_challenge_resolution",
581        description = "Wait for mark_challenge_resolved/failed on an active challenge event. When \
582resolved, re-probes the DOM by default so callers can confirm the wall is gone before resuming."
583    )]
584    pub async fn wait_for_challenge_resolution(
585        &self,
586        Parameters(args): Parameters<WaitChallengeArgs>,
587    ) -> Result<Json<WaitChallengeResult>, ErrorData> {
588        tools::challenge::wait_for_resolution(self, args).await.map(Json)
589    }
590}
591
592#[tool_handler]
593impl ServerHandler for VoidCrawlServer {
594    fn get_info(&self) -> ServerInfo {
595        let mut info = ServerInfo::default();
596        info.protocol_version = ProtocolVersion::default();
597        info.capabilities = ServerCapabilities::builder().enable_tools().build();
598        info.server_info = {
599            let mut imp = Implementation::default();
600            imp.name = "voidcrawl-mcp".into();
601            imp.version = VERSION.into();
602            imp
603        };
604        // Shipped to EVERY MCP client on connect (Claude, opencode, Codex,
605        // Cursor, Cline, Zed, …), so the AX-first workflow + gotchas reach
606        // hosts that have no skill-file mechanism. Keep this condensed; the
607        // full guide is .claude/skills/voidcrawl/SKILL.md.
608        info.instructions = Some(
609            "Stealthy headless Chrome over a shared, fingerprint-patched tab pool — a drop-in \
610replacement for Playwright / Chromium MCP.\n\n\
611WORKFLOW. Stateless perception: `fetch_snapshot` for a compact rendered-page snapshot. Stateless \
612raw scrape: `fetch` (one URL) or `fetch_many` (parallel; returns \
613{results:[{ok,result,error}]} in input order — per-item errors don't abort the batch, and \
614status_code is nested under each item's `result`). Stateful flows (login, pagination, clicking): \
615`session_open` → `session_navigate` → `session_snapshot` / actions → … → `session_close`. ALWAYS \
616session_close; sessions are cookie-isolated.\n\n\
617PERCEIVE → ACT → EXTRACT. To inspect a large rendered page, prefer `fetch_snapshot` first, or \
618`session_snapshot` after clicking/pagination/login flows. For role/name interaction targeting, \
619call `session_ax_tree` — a compact outline of the accessibility tree. If `named_count` is low vs \
620`node_count` the accessibility tree is thin; fall back to `session_snapshot` or `screenshot`. Use \
621raw `fetch` / `session_content` only when you truly need markup. To click: `click` (CSS selector) \
622or `click_by_role` (accessibility role + accessible name — durable across redesigns); last resort \
623`click_visual_coords` for React forms that ignore synthetic clicks. To extract data, run `extract` \
624/ `eval_js` with a JS expression and return data, not markup.\n\n\
625GOTCHAS. `click_by_role` name matching is EXACT (case + whitespace) — read the exact name from \
626`session_ax_tree` first; use `nth` for duplicates. After an in-page (SPA) click, \
627`wait_for_network_idle` may run to its full timeout — pass a short `timeout_secs` or use \
628`wait_for:\"selector:<css>\"`. On a captcha error, surface it and rotate proxy/profile; don't \
629retry the same URL. For operator handoff, use `capture_challenge` to get same-tab attach \
630coordinates plus VNC/noVNC links, clear the wall manually, then call \
631`mark_challenge_resolved` and `wait_for_challenge_resolution`. Phase-3 automated resolvers \
632attach to the same `{websocket_url,target_id}` and must mark resolved or failed."
633                .into(),
634        );
635        info
636    }
637}