1use 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#[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 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}