1use std::{
85 collections::HashMap,
86 sync::{Arc, LazyLock},
87 time::Duration,
88};
89
90use chromiumoxide::Browser;
91use serde::{Deserialize, Serialize};
92use serde_json::{Value, json};
93use tokio::{
94 io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
95 sync::Mutex,
96 task::JoinHandle,
97 time::sleep,
98};
99use tracing::{debug, info};
100use ulid::Ulid;
101
102#[cfg(feature = "mcp-attach")]
103use futures::StreamExt;
104
105use crate::{
106 AcquisitionMode, AcquisitionRequest, AcquisitionResult, AcquisitionRunner, BrowserConfig,
107 BrowserHandle, BrowserPool,
108 behavior::{InteractionLevel, InteractionSimulator},
109 behavior_adapter::{BehaviorInteractionLevel, PolymorphicBehaviorAdapter},
110 config::StealthLevel,
111 error::{BrowserError, Result},
112 page::WaitUntil,
113 session::{SessionSnapshot, restore_session, save_session},
114};
115
116#[derive(Debug, Deserialize)]
120pub struct JsonRpcRequest {
121 pub jsonrpc: String,
123 pub method: String,
125 #[serde(default)]
127 pub params: Value,
128 #[serde(default)]
130 pub id: Value,
131}
132
133#[derive(Debug, Serialize)]
135pub struct JsonRpcResponse {
136 jsonrpc: &'static str,
137 #[serde(skip_serializing_if = "Option::is_none")]
138 result: Option<Value>,
139 #[serde(skip_serializing_if = "Option::is_none")]
140 error: Option<JsonRpcError>,
141 id: Value,
142}
143
144#[derive(Debug, Serialize)]
146pub struct JsonRpcError {
147 code: i32,
148 message: String,
149 #[serde(skip_serializing_if = "Option::is_none")]
150 data: Option<Value>,
151}
152
153impl JsonRpcResponse {
154 fn ok(id: Value, result: Value) -> Self {
160 let wrapped = match result {
161 Value::Object(mut obj) => {
162 obj.insert("resultType".to_owned(), json!("complete"));
163 Value::Object(obj)
164 }
165 other => {
166 let mut obj = serde_json::Map::new();
167 obj.insert("resultType".to_owned(), json!("complete"));
168 obj.insert("value".to_owned(), other);
169 Value::Object(obj)
170 }
171 };
172 Self {
173 jsonrpc: "2.0",
174 result: Some(wrapped),
175 error: None,
176 id,
177 }
178 }
179
180 fn err(id: Value, code: i32, message: impl Into<String>) -> Self {
181 Self {
182 jsonrpc: "2.0",
183 result: None,
184 error: Some(JsonRpcError {
185 code,
186 message: message.into(),
187 data: None,
188 }),
189 id,
190 }
191 }
192
193 fn method_not_found(id: Value, method: &str) -> Self {
194 Self::err(id, -32601, format!("Method not found: {method}"))
195 }
196}
197
198#[allow(dead_code)] fn extract_meta<'a>(req: &'a Value, key: &str) -> Option<&'a Value> {
209 let meta = req.get("params")?.get("_meta")?.as_object()?;
210 meta.get(&format!("io.modelcontextprotocol/{key}"))
211}
212
213#[allow(dead_code)] fn extract_client_protocol_version(req: &Value) -> Option<String> {
222 extract_meta(req, "protocolVersion")
223 .and_then(Value::as_str)
224 .map(str::to_owned)
225}
226
227#[cfg(test)]
239fn is_supported_protocol_version(
240 client: &str,
241 supported: &[&str],
242) -> std::result::Result<(), String> {
243 if supported.contains(&client) {
244 Ok(())
245 } else {
246 Err(format!("Unsupported protocol version: {client}"))
247 }
248}
249
250struct McpSession {
258 handle: Arc<Mutex<Option<BrowserHandle>>>,
260 attached_browser: Arc<Mutex<Option<Browser>>>,
262 attached_handler_task: Arc<Mutex<Option<JoinHandle<()>>>>,
264 page: Arc<Mutex<Option<crate::page::PageHandle>>>,
266 stealth_level: StealthLevel,
268 tls_profile: Option<String>,
270 webrtc_policy: Option<String>,
272 cdp_fix_mode: Option<String>,
274 proxy: Option<String>,
276 target_profile: String,
278 current_url: Option<String>,
280 saved_snapshot: Option<SessionSnapshot>,
282 attach_endpoint: Option<String>,
284 behavior_plan: Option<crate::behavior_adapter::AppliedBehaviorPlan>,
286}
287
288static TOOL_DEFINITIONS: LazyLock<Vec<Value>> = LazyLock::new(|| {
307 let mut tools = vec![
308 json!({
309 "name": "browser_acquire",
310 "description": "Acquire a browser from the pool and open a session. The optional parameters are stored as session metadata labels and echoed back in the response; they do not reconfigure the pool-acquired browser at runtime. Use them to annotate sessions (e.g. for `browser_verify_stealth` attribution).",
311 "inputSchema": {
312 "type": "object",
313 "properties": {
314 "stealth_level": {
315 "type": "string",
316 "enum": ["none", "basic", "advanced"],
317 "description": "Anti-detection intensity. Defaults to 'advanced'."
318 },
319 "tls_profile": {
320 "type": "string",
321 "description": "TLS fingerprint profile label (free-form; requires stealth feature; browser-launch-level). Examples: chrome131, firefox133, safari18, edge131."
322 },
323 "webrtc_policy": {
324 "type": "string",
325 "description": "WebRTC IP-leak policy label (free-form; requires stealth feature; browser-launch-level). Examples: allow_all, disable_non_proxied, block_all."
326 },
327 "cdp_fix_mode": {
328 "type": "string",
329 "enum": ["addBinding", "isolatedWorld", "enableDisable", "none"],
330 "description": "CDP Runtime.enable leak-mitigation mode."
331 },
332 "proxy": {
333 "type": "string",
334 "description": "HTTP/SOCKS proxy URL, e.g. 'http://user:pass@host:port'. Only pass this when the user has explicitly requested proxy use or you have already acquired a proxy via proxy_acquire. Do NOT populate this field by default."
335 },
336 "target_profile": {
337 "type": "string",
338 "enum": ["default", "reddit"],
339 "description": "Optional target tuning profile. 'reddit' enables challenge-aware waits and stabilization tuned for Reddit flows."
340 }
341 },
342 "required": []
343 }
344 }),
345 json!({
346 "name": "browser_navigate",
347 "description": "Navigate to a URL within a session. Opens a new page if needed.",
348 "inputSchema": {
349 "type": "object",
350 "properties": {
351 "session_id": { "type": "string" },
352 "url": { "type": "string" },
353 "timeout_secs": { "type": "integer", "default": 30 }
354 },
355 "required": ["session_id", "url"]
356 }
357 }),
358 json!({
359 "name": "browser_acquire_and_extract",
360 "description": "Run the opinionated acquisition ladder and return structured extraction/content output in one call. Uses AcquisitionRunner facade with deterministic strategy escalation.",
361 "inputSchema": {
362 "type": "object",
363 "properties": {
364 "url": { "type": "string", "description": "Target URL to acquire." },
365 "mode": {
366 "type": "string",
367 "enum": ["fast", "resilient", "hostile", "investigate"],
368 "description": "Acquisition ladder mode."
369 },
370 "wait_for_selector": {
371 "type": "string",
372 "description": "Optional selector wait gate for browser-stage success."
373 },
374 "selector_wait": {
375 "type": "string",
376 "description": "Alias for wait_for_selector."
377 },
378 "extraction_js": {
379 "type": "string",
380 "description": "Optional JavaScript extraction expression evaluated in browser stages."
381 },
382 "total_timeout_secs": {
383 "type": "number",
384 "default": 45,
385 "description": "Optional wall-clock timeout for the full acquisition run."
386 }
387 },
388 "required": ["url", "mode"]
389 }
390 }),
391 json!({
392 "name": "browser_eval",
393 "description": "Evaluate JavaScript in the current page of a session.",
394 "inputSchema": {
395 "type": "object",
396 "properties": {
397 "session_id": { "type": "string" },
398 "script": { "type": "string" }
399 },
400 "required": ["session_id", "script"]
401 }
402 }),
403 json!({
404 "name": "browser_screenshot",
405 "description": "Capture a full-page PNG screenshot. Returns base64-encoded PNG.",
406 "inputSchema": {
407 "type": "object",
408 "properties": {
409 "session_id": { "type": "string" }
410 },
411 "required": ["session_id"]
412 }
413 }),
414 json!({
415 "name": "browser_content",
416 "description": "Get the full HTML content of the current page.",
417 "inputSchema": {
418 "type": "object",
419 "properties": {
420 "session_id": { "type": "string" }
421 },
422 "required": ["session_id"]
423 }
424 }),
425 #[cfg(feature = "mcp-attach")]
426 json!({
427 "name": "browser_attach",
428 "description": "Attach MCP workflows to an existing user browser/profile context. `cdp_ws` mode is implemented and creates a live attached session; `extension_bridge` remains a contract-only path.",
429 "inputSchema": {
430 "type": "object",
431 "properties": {
432 "mode": {
433 "type": "string",
434 "enum": ["extension_bridge", "cdp_ws"],
435 "description": "Attach strategy. extension_bridge is the recommended future path for existing user profiles. cdp_ws targets a remote debugging websocket endpoint."
436 },
437 "endpoint": {
438 "type": "string",
439 "description": "Optional endpoint for cdp_ws mode, e.g. ws://127.0.0.1:9222/devtools/browser/<id>."
440 },
441 "profile_hint": {
442 "type": "string",
443 "description": "Optional human-readable profile label (e.g. 'reddit-main')."
444 },
445 "target_profile": {
446 "type": "string",
447 "enum": ["default", "reddit"],
448 "description": "Optional target tuning profile used by session navigation helpers."
449 }
450 },
451 "required": ["mode"]
452 }
453 }),
454 json!({
455 "name": "browser_auth_session",
456 "description": "High-level auth/session workflow wrapper. Use mode='capture' to persist login state and mode='resume' to restore it.",
457 "inputSchema": {
458 "type": "object",
459 "properties": {
460 "session_id": { "type": "string" },
461 "mode": { "type": "string", "enum": ["capture", "resume"] },
462 "file_path": { "type": "string", "description": "Optional snapshot file path for durable persistence." },
463 "ttl_secs": { "type": "integer", "description": "Optional TTL (seconds) when capturing." },
464 "navigate_to_origin": { "type": "boolean", "default": true, "description": "When resuming, navigate to snapshot origin before restore." },
465 "interaction_level": { "type": "string", "enum": ["none", "low", "medium", "high"], "default": "none", "description": "Optional post-operation human-like interaction step." }
466 },
467 "required": ["session_id", "mode"]
468 }
469 }),
470 json!({
471 "name": "browser_release",
472 "description": "Release a browser session back to the pool.",
473 "inputSchema": {
474 "type": "object",
475 "properties": {
476 "session_id": { "type": "string" }
477 },
478 "required": ["session_id"]
479 }
480 }),
481 json!({
482 "name": "pool_stats",
483 "description": "Return current browser pool statistics.",
484 "inputSchema": {
485 "type": "object",
486 "properties": {},
487 "required": []
488 }
489 }),
490 ];
491 tools.push(json!({
492 "name": "browser_query",
493 "description": "Navigate to a URL, query all elements matching a CSS selector, and return their text content or specific attributes. If `fields` is omitted each result is a plain string (the text content). If `fields` is supplied each result is an object with one key per field.",
494 "inputSchema": {
495 "type": "object",
496 "properties": {
497 "session_id": { "type": "string" },
498 "url": { "type": "string" },
499 "selector": { "type": "string", "description": "CSS selector passed to querySelectorAll." },
500 "fields": {
501 "type": "object",
502 "description": "Map of output field name → { \"attr\": \"attribute-name\" }. Omit `attr` to get text content for that field.",
503 "additionalProperties": {
504 "type": "object",
505 "properties": { "attr": { "type": "string" } }
506 }
507 },
508 "limit": { "type": "integer", "default": 50, "description": "Maximum number of nodes to return." },
509 "timeout_secs": { "type": "number", "default": 30 }
510 },
511 "required": ["session_id", "url", "selector"]
512 }
513 }));
514 tools.push(json!({
515 "name": "browser_extract",
516 "description": "Navigate to a URL and perform schema-driven structured extraction. Each element matching `root_selector` becomes one result object; fields within each root are resolved by their own sub-selectors relative to the root. This is the runtime equivalent of the `#[derive(Extract)]` macro.",
517 "inputSchema": {
518 "type": "object",
519 "properties": {
520 "session_id": { "type": "string" },
521 "url": { "type": "string" },
522 "root_selector": { "type": "string", "description": "CSS selector whose matches become the root of each result object." },
523 "schema": {
524 "type": "object",
525 "description": "Map of field name → { \"selector\": \"...\", \"attr\": \"...\", \"required\": true/false }.",
526 "additionalProperties": {
527 "type": "object",
528 "properties": {
529 "selector": { "type": "string" },
530 "attr": { "type": "string" },
531 "required": { "type": "boolean", "default": false }
532 },
533 "required": ["selector"]
534 }
535 },
536 "timeout_secs": { "type": "number", "default": 30 }
537 },
538 "required": ["session_id", "url", "root_selector", "schema"]
539 }
540 }));
541 tools.push(json!({
542 "name": "browser_extract_with_fallback",
543 "description": "Like browser_extract but accepts multiple root selectors (tried in order). Returns the first selector that produces results. Useful when a site layout may have changed and you want to try modern markup before falling back to legacy selectors.",
544 "inputSchema": {
545 "type": "object",
546 "properties": {
547 "session_id": { "type": "string" },
548 "url": { "type": "string" },
549 "root_selectors": {
550 "type": "array",
551 "items": { "type": "string" },
552 "description": "CSS selectors tried in order; the first that produces results is used.",
553 "minItems": 1
554 },
555 "schema": {
556 "type": "object",
557 "description": "Map of field name → { \"selector\": \"...\", \"attr\": \"...\", \"required\": true/false }.",
558 "additionalProperties": {
559 "type": "object",
560 "properties": {
561 "selector": { "type": "string" },
562 "attr": { "type": "string" },
563 "required": { "type": "boolean", "default": false }
564 },
565 "required": ["selector"]
566 }
567 },
568 "timeout_secs": { "type": "number", "default": 30 }
569 },
570 "required": ["session_id", "url", "root_selectors", "schema"]
571 }
572 }));
573 tools.push(json!({
574 "name": "browser_extract_resilient",
575 "description": "Like browser_extract but skips root nodes where *all* required schema fields are absent (partial records). Useful for heterogeneous lists where some items lack an optional field.",
576 "inputSchema": {
577 "type": "object",
578 "properties": {
579 "session_id": { "type": "string" },
580 "url": { "type": "string" },
581 "root_selector": { "type": "string", "description": "CSS selector whose matches become the root of each result object." },
582 "schema": {
583 "type": "object",
584 "description": "Map of field name → { \"selector\": \"...\", \"attr\": \"...\", \"required\": true/false }.",
585 "additionalProperties": {
586 "type": "object",
587 "properties": {
588 "selector": { "type": "string" },
589 "attr": { "type": "string" },
590 "required": { "type": "boolean", "default": false }
591 },
592 "required": ["selector"]
593 }
594 },
595 "timeout_secs": { "type": "number", "default": 30 }
596 },
597 "required": ["session_id", "url", "root_selector", "schema"]
598 }
599 }));
600 #[cfg(feature = "similarity")]
602 tools.push(json!({
603 "name": "browser_find_similar",
604 "description": "Navigate to a URL and find DOM elements that are structurally similar to a reference element (identified by a CSS selector). Useful when a site has been redesigned and stored selectors no longer match. Requires the `similarity` feature.",
605 "inputSchema": {
606 "type": "object",
607 "properties": {
608 "session_id": { "type": "string" },
609 "url": { "type": "string" },
610 "reference_selector": { "type": "string", "description": "CSS selector identifying the reference node. The first match is used." },
611 "threshold": { "type": "number", "default": 0.7, "description": "Minimum similarity score [0.0, 1.0]." },
612 "max_results": { "type": "integer", "default": 10 },
613 "timeout_secs": { "type": "number", "default": 30 }
614 },
615 "required": ["session_id", "url", "reference_selector"]
616 }
617 }));
618 #[cfg(feature = "stealth")]
620 tools.push(json!({
621 "name": "browser_verify_stealth",
622 "description": "Navigate to a URL and run built-in stealth checks with optional transport diagnostics (JA3/JA4/HTTP3). Returns a DiagnosticReport with pass/fail results, coverage percentage, transport mismatch details, and known_limitations for visible-but-not-yet-covered surfaces.",
623 "inputSchema": {
624 "type": "object",
625 "properties": {
626 "session_id": { "type": "string" },
627 "url": { "type": "string", "description": "URL to navigate to before running checks." },
628 "timeout_secs": { "type": "integer", "default": 15, "description": "Navigation timeout in seconds." },
629 "observed_ja3_hash": { "type": "string", "description": "Optional observed JA3 hash to compare against expected profile." },
630 "observed_ja4": { "type": "string", "description": "Optional observed JA4 fingerprint to compare against expected profile." },
631 "observed_http3_perk_text": { "type": "string", "description": "Optional observed HTTP/3 perk text (SETTINGS|PSEUDO_HEADERS)." },
632 "observed_http3_perk_hash": { "type": "string", "description": "Optional observed HTTP/3 perk hash." }
633 },
634 "required": ["session_id", "url"]
635 }
636 }));
637 #[cfg(feature = "stealth")]
639 tools.push(json!({
640 "name": "browser_validate_stealth",
641 "description": "Run anti-bot service validators against the pool (Tier 1: CreepJS, BrowserScan). Returns a summary report.",
642 "inputSchema": {
643 "type": "object",
644 "properties": {
645 "targets": {
646 "type": "array",
647 "items": { "type": "string", "enum": ["creepjs", "browserscan", "fingerprint_js", "kasada", "cloudflare", "akamai", "data_dome", "perimeter_x"] },
648 "description": "List of services to validate. Empty = Tier 1 only. Tier 2+ tests may rate-limit.",
649 "default": ["creepjs", "browserscan"]
650 },
651 "tier1_only": {
652 "type": "boolean",
653 "default": false,
654 "description": "If true, force regression-safe Tier 1 targets only (CreepJS + BrowserScan)."
655 },
656 "timeout_secs": { "type": "integer", "default": 30, "description": "Per-target timeout in seconds." }
657 },
658 "required": []
659 }
660 }));
661 tools.push(json!({
663 "name": "browser_warmup",
664 "description": "Warm up a browser session by navigating to a URL and optionally waiting for dynamic resources to settle. Warmup is idempotent — calling it again re-warms the same session.",
665 "inputSchema": {
666 "type": "object",
667 "properties": {
668 "session_id": { "type": "string" },
669 "url": { "type": "string", "description": "URL to navigate to during warmup." },
670 "wait": {
671 "type": "string",
672 "enum": ["dom_content_loaded", "network_idle"],
673 "default": "dom_content_loaded",
674 "description": "Wait strategy after navigation."
675 },
676 "timeout_ms": { "type": "integer", "default": 30000, "description": "Navigation timeout in milliseconds." },
677 "stabilize_ms": { "type": "integer", "default": 0, "description": "Additional pause after navigation for dynamic resources to settle (0 = skip)." }
678 },
679 "required": ["session_id", "url"]
680 }
681 }));
682 tools.push(json!({
683 "name": "browser_refresh",
684 "description": "Refresh the current page while retaining cookies and session storage. Optionally re-navigates to force a new TCP connection.",
685 "inputSchema": {
686 "type": "object",
687 "properties": {
688 "session_id": { "type": "string" },
689 "wait": {
690 "type": "string",
691 "enum": ["dom_content_loaded", "network_idle"],
692 "default": "dom_content_loaded",
693 "description": "Wait strategy after reload."
694 },
695 "timeout_ms": { "type": "integer", "default": 30000, "description": "Reload timeout in milliseconds." },
696 "reset_connection": { "type": "boolean", "default": false, "description": "When true, re-navigates to force a new TCP connection instead of in-place reload." }
697 },
698 "required": ["session_id"]
699 }
700 }));
701 tools.push(json!({
702 "name": "browser_session_save",
703 "description": "Save current browser session state (cookies + localStorage) to memory and optionally to disk.",
704 "inputSchema": {
705 "type": "object",
706 "properties": {
707 "session_id": { "type": "string" },
708 "ttl_secs": { "type": "integer", "description": "Optional snapshot TTL in seconds." },
709 "file_path": { "type": "string", "description": "Optional path to save session snapshot JSON." },
710 "include_snapshot": { "type": "boolean", "default": false, "description": "When true, include full snapshot payload in response." }
711 },
712 "required": ["session_id"]
713 }
714 }));
715 tools.push(json!({
716 "name": "browser_session_restore",
717 "description": "Restore browser session state from provided snapshot JSON, saved in-memory snapshot, or file.",
718 "inputSchema": {
719 "type": "object",
720 "properties": {
721 "session_id": { "type": "string" },
722 "snapshot": { "type": "object", "description": "Inline SessionSnapshot JSON." },
723 "file_path": { "type": "string", "description": "Path to a SessionSnapshot JSON file." },
724 "use_saved": { "type": "boolean", "default": true, "description": "Use in-memory snapshot when no inline/file snapshot is provided." },
725 "navigate_to_origin": { "type": "boolean", "default": true, "description": "Navigate to snapshot origin before restore when origin is present." }
726 },
727 "required": ["session_id"]
728 }
729 }));
730 tools.push(json!({
731 "name": "browser_humanize",
732 "description": "Apply human-like interaction sequence on current page (scroll, key activity, mouse movement).",
733 "inputSchema": {
734 "type": "object",
735 "properties": {
736 "session_id": { "type": "string" },
737 "level": { "type": "string", "enum": ["none", "low", "medium", "high"], "default": "low" },
738 "viewport_width": { "type": "number", "default": 1366.0 },
739 "viewport_height": { "type": "number", "default": 768.0 }
740 },
741 "required": ["session_id"]
742 }
743 }));
744 tools.push(json!({
745 "name": "browser_apply_behavior_json",
746 "description": "Apply structured behavior JSON (runtime policy, investigation bundle, or direct overrides) using the polymorphic behavior adapter. Returns an applied plan and effective browser config. If session_id is provided, session metadata is updated for downstream tools.",
747 "inputSchema": {
748 "type": "object",
749 "properties": {
750 "behavior": {
751 "type": "object",
752 "description": "Structured behavior input: RuntimePolicy object, InvestigationBundle object with nested policy, or direct override object."
753 },
754 "session_id": {
755 "type": "string",
756 "description": "Optional active session to annotate with the applied behavior plan."
757 }
758 },
759 "required": ["behavior"]
760 }
761 }));
762 tools
763});
764
765pub struct McpBrowserServer {
766 pool: Arc<BrowserPool>,
767 sessions: Arc<Mutex<HashMap<String, McpSession>>>,
768}
769
770struct ExtractFieldDef {
772 selector: String,
773 attr: Option<String>,
774 required: bool,
775}
776
777impl McpBrowserServer {
778 #[must_use]
782 pub fn new(pool: Arc<BrowserPool>) -> Self {
783 Self {
784 pool,
785 sessions: Arc::new(Mutex::new(HashMap::new())),
786 }
787 }
788
789 pub async fn run(&self) -> Result<()> {
798 info!("MCP browser server starting (stdin/stdout mode)");
799
800 let stdin = tokio::io::stdin();
801 let stdout = tokio::io::stdout();
802 let mut reader = BufReader::new(stdin).lines();
803 let mut stdout = stdout;
804
805 while let Some(line) = reader.next_line().await.map_err(BrowserError::Io)? {
806 let line = line.trim().to_string();
807 if line.is_empty() {
808 continue;
809 }
810
811 debug!(?line, "MCP request");
812
813 let response = match serde_json::from_str::<Value>(&line) {
814 Ok(req) => {
815 let is_well_formed_notification = req.is_object()
816 && req.get("jsonrpc").and_then(Value::as_str) == Some("2.0")
817 && req.get("id").is_none()
818 && req.get("method").and_then(Value::as_str).is_some();
819 let response = self.dispatch(&req).await;
820 if is_well_formed_notification {
821 continue;
822 }
823 response
824 }
825 Err(e) => serde_json::to_value(JsonRpcResponse::err(
826 Value::Null,
827 -32700,
828 format!("Parse error: {e}"),
829 ))
830 .unwrap_or_else(|_| {
831 json!({"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"Internal error"}})
832 }),
833 };
834
835 let mut out = serde_json::to_string(&response).unwrap_or_default();
836 out.push('\n');
837 stdout
838 .write_all(out.as_bytes())
839 .await
840 .map_err(BrowserError::Io)?;
841 stdout.flush().await.map_err(BrowserError::Io)?;
842 }
843
844 info!("MCP browser server stopping (stdin closed)");
845 Ok(())
846 }
847
848 pub async fn dispatch(&self, req: &Value) -> Value {
871 let typed: JsonRpcRequest = match serde_json::from_value(req.clone()) {
872 Ok(r) => r,
873 Err(e) => {
874 return json!({
875 "jsonrpc": "2.0",
876 "id": req.get("id").cloned().unwrap_or(Value::Null),
877 "error": { "code": -32700, "message": format!("Parse error: {e}") }
878 });
879 }
880 };
881 let resp = self.handle_request(typed).await;
882 serde_json::to_value(resp).unwrap_or_else(|_| json!({"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"Internal error"}}))
883 }
884
885 async fn handle_request(&self, req: JsonRpcRequest) -> JsonRpcResponse {
886 let id = req.id.clone();
887 match req.method.as_str() {
888 "server/discover" => Self::handle_discover(id),
892 "tools/list" => Self::handle_tools_list(id),
893 "tools/call" => self.handle_tools_call(id, req.params).await,
894 "resources/list" => self.handle_resources_list(id).await,
895 "resources/read" => self.handle_resources_read(id, req.params).await,
896 other => JsonRpcResponse::method_not_found(id, other),
897 }
898 }
899
900 fn handle_discover(id: Value) -> JsonRpcResponse {
913 JsonRpcResponse::ok(
914 id,
915 json!({
916 "protocolVersion": "2026-07-28",
917 "supportedProtocolVersions": ["2026-07-28"],
918 "capabilities": {
919 "tools": { "listChanged": false },
920 "resources": { "listChanged": false }
921 },
922 "serverInfo": {
923 "name": "stygian-browser",
924 "version": env!("CARGO_PKG_VERSION")
925 },
926 "extensions": []
927 }),
928 )
929 }
930
931 fn handle_tools_list(id: Value) -> JsonRpcResponse {
934 JsonRpcResponse::ok(id, json!({ "tools": &*TOOL_DEFINITIONS }))
935 }
936
937 async fn handle_tools_call(&self, id: Value, params: Value) -> JsonRpcResponse {
940 let name = match params.get("name").and_then(|v| v.as_str()) {
941 Some(n) => n.to_string(),
942 None => return JsonRpcResponse::err(id, -32602, "Missing tool 'name'"),
943 };
944 let args = params
945 .get("arguments")
946 .cloned()
947 .unwrap_or_else(|| json!({}));
948
949 let result = match name.as_str() {
950 "browser_acquire" => self.tool_browser_acquire(&args).await,
951 "browser_acquire_and_extract" => self.tool_browser_acquire_and_extract(&args).await,
952 "browser_navigate" => self.tool_browser_navigate(&args).await,
953 "browser_eval" => self.tool_browser_eval(&args).await,
954 "browser_screenshot" => self.tool_browser_screenshot(&args).await,
955 "browser_content" => self.tool_browser_content(&args).await,
956 #[cfg(feature = "mcp-attach")]
957 "browser_attach" => self.tool_browser_attach(&args).await,
958 #[cfg(not(feature = "mcp-attach"))]
959 "browser_attach" => Err(BrowserError::ConfigError(
960 "browser_attach requires the 'mcp-attach' feature".to_string(),
961 )),
962 "browser_auth_session" => self.tool_browser_auth_session(&args).await,
963 "browser_session_save" => self.tool_browser_session_save(&args).await,
964 "browser_session_restore" => self.tool_browser_session_restore(&args).await,
965 "browser_apply_behavior_json" => self.tool_browser_apply_behavior_json(&args).await,
966 "browser_humanize" => self.tool_browser_humanize(&args).await,
967 #[cfg(feature = "stealth")]
968 "browser_verify_stealth" => self.tool_browser_verify_stealth(&args).await,
969 #[cfg(not(feature = "stealth"))]
970 "browser_verify_stealth" => Err(BrowserError::ConfigError(
971 "browser_verify_stealth requires the 'stealth' feature".to_string(),
972 )),
973 #[cfg(feature = "stealth")]
974 "browser_validate_stealth" => self.tool_browser_validate_stealth(&args).await,
975 #[cfg(not(feature = "stealth"))]
976 "browser_validate_stealth" => Err(BrowserError::ConfigError(
977 "browser_validate_stealth requires the 'stealth' feature".to_string(),
978 )),
979 "browser_release" => self.tool_browser_release(&args).await,
980 "pool_stats" => Ok(self.tool_pool_stats()),
981 "browser_query" => self.tool_browser_query(&args).await,
982 "browser_extract" => self.tool_browser_extract(&args).await,
983 "browser_extract_with_fallback" => self.tool_browser_extract_with_fallback(&args).await,
984 "browser_extract_resilient" => self.tool_browser_extract_resilient(&args).await,
985 #[cfg(feature = "similarity")]
986 "browser_find_similar" => self.tool_browser_find_similar(&args).await,
987 "browser_warmup" => self.tool_browser_warmup(&args).await,
988 "browser_refresh" => self.tool_browser_refresh(&args).await,
989 other => Err(BrowserError::ConfigError(format!("Unknown tool: {other}"))),
990 };
991
992 match result {
993 Ok(content) => JsonRpcResponse::ok(
994 id,
995 json!({ "content": [{ "type": "text", "text": content.to_string() }], "isError": false }),
996 ),
997 Err(e) => JsonRpcResponse::ok(
998 id,
999 json!({ "content": [{ "type": "text", "text": e.to_string() }], "isError": true }),
1000 ),
1001 }
1002 }
1003
1004 async fn tool_browser_acquire(&self, args: &Value) -> Result<Value> {
1005 let stealth_level = args
1007 .get("stealth_level")
1008 .and_then(|v| v.as_str())
1009 .map(|s| match s {
1010 "none" => StealthLevel::None,
1011 "basic" => StealthLevel::Basic,
1012 _ => StealthLevel::Advanced,
1013 })
1014 .unwrap_or_default();
1015 let tls_profile = args
1016 .get("tls_profile")
1017 .and_then(|v| v.as_str())
1018 .map(ToString::to_string);
1019 let webrtc_policy = args
1020 .get("webrtc_policy")
1021 .and_then(|v| v.as_str())
1022 .map(ToString::to_string);
1023 let cdp_fix_mode = args
1024 .get("cdp_fix_mode")
1025 .and_then(|v| v.as_str())
1026 .map(ToString::to_string);
1027 let proxy = args
1028 .get("proxy")
1029 .and_then(|v| v.as_str())
1030 .map(ToString::to_string);
1031 let target_profile = args
1032 .get("target_profile")
1033 .and_then(|v| v.as_str())
1034 .map_or_else(
1035 || "default".to_string(),
1036 |s| {
1037 if s.eq_ignore_ascii_case("reddit") {
1038 "reddit".to_string()
1039 } else {
1040 "default".to_string()
1041 }
1042 },
1043 );
1044
1045 let handle = self.pool.acquire().await?;
1046 let session_id = Ulid::new().to_string();
1047
1048 let effective_stealth = format!("{stealth_level:?}").to_lowercase();
1049 self.sessions.lock().await.insert(
1050 session_id.clone(),
1051 McpSession {
1052 handle: Arc::new(Mutex::new(Some(handle))),
1053 attached_browser: Arc::new(Mutex::new(None)),
1054 attached_handler_task: Arc::new(Mutex::new(None)),
1055 page: Arc::new(Mutex::new(None)),
1056 stealth_level,
1057 tls_profile: tls_profile.clone(),
1058 webrtc_policy: webrtc_policy.clone(),
1059 cdp_fix_mode: cdp_fix_mode.clone(),
1060 proxy: proxy.clone(),
1061 target_profile: target_profile.clone(),
1062 current_url: None,
1063 saved_snapshot: None,
1064 attach_endpoint: None,
1065 behavior_plan: None,
1066 },
1067 );
1068
1069 info!(%session_id, %effective_stealth, "MCP session acquired");
1070 Ok(json!({
1071 "session_id": session_id,
1072 "requested_metadata": {
1073 "stealth_level": effective_stealth,
1074 "tls_profile": tls_profile,
1075 "webrtc_policy": webrtc_policy,
1076 "cdp_fix_mode": cdp_fix_mode,
1077 "proxy": proxy,
1078 "target_profile": target_profile
1079 }
1080 }))
1081 }
1082
1083 async fn tool_browser_acquire_and_extract(&self, args: &Value) -> Result<Value> {
1084 let request = Self::parse_acquisition_request(args)?;
1085 let runner = AcquisitionRunner::new(self.pool.clone());
1086 let result = runner.run(request).await;
1087 Ok(Self::acquisition_result_to_tool_output(&result))
1088 }
1089
1090 #[cfg(feature = "stealth")]
1091 async fn tool_browser_verify_stealth(&self, args: &Value) -> Result<Value> {
1092 let session_id = Self::require_str(args, "session_id")?;
1093 let url = Self::require_str(args, "url")?;
1094 let timeout_secs = args
1095 .get("timeout_secs")
1096 .and_then(serde_json::Value::as_u64)
1097 .unwrap_or(15);
1098 let observed = crate::diagnostic::TransportObservations {
1099 ja3_hash: args
1100 .get("observed_ja3_hash")
1101 .and_then(serde_json::Value::as_str)
1102 .map(ToString::to_string),
1103 ja4: args
1104 .get("observed_ja4")
1105 .and_then(serde_json::Value::as_str)
1106 .map(ToString::to_string),
1107 http3_perk_text: args
1108 .get("observed_http3_perk_text")
1109 .and_then(serde_json::Value::as_str)
1110 .map(ToString::to_string),
1111 http3_perk_hash: args
1112 .get("observed_http3_perk_hash")
1113 .and_then(serde_json::Value::as_str)
1114 .map(ToString::to_string),
1115 };
1116
1117 let (session_arc, attached_browser_arc, page_arc, _, reddit_profile) =
1118 self.session_runtime(&session_id).await?;
1119 let requested_stealth = self.session_handle_and_stealth(&session_id).await?.1;
1120
1121 self.ensure_session_page(
1122 &session_id,
1123 &session_arc,
1124 &attached_browser_arc,
1125 &page_arc,
1126 None,
1127 Duration::from_secs(timeout_secs),
1128 reddit_profile,
1129 )
1130 .await?;
1131
1132 {
1133 let mut page_guard = page_arc.lock().await;
1134 let page = page_guard.as_mut().ok_or_else(|| {
1135 BrowserError::ConfigError(format!("Session page unavailable: {session_id}"))
1136 })?;
1137 Self::navigate_with_profile(
1138 page,
1139 &url,
1140 Duration::from_secs(timeout_secs),
1141 reddit_profile,
1142 )
1143 .await?;
1144 drop(page_guard);
1145 }
1146
1147 let mut result = {
1148 let page_guard = page_arc.lock().await;
1149 let page = page_guard.as_ref().ok_or_else(|| {
1150 BrowserError::ConfigError(format!("Session page unavailable: {session_id}"))
1151 })?;
1152 let result = Self::run_stealth_diagnostic(page, observed).await;
1153 drop(page_guard);
1154 result
1155 };
1156
1157 if let Some(session) = self.sessions.lock().await.get_mut(&session_id) {
1158 session.current_url = Some(url.clone());
1159 }
1160
1161 if let Ok(ref mut v) = result
1163 && let Some(obj) = v.as_object_mut()
1164 {
1165 obj.insert(
1166 "requested_stealth_level".to_string(),
1167 Value::String(requested_stealth),
1168 );
1169 }
1170 result
1171 }
1172
1173 #[cfg(feature = "stealth")]
1174 async fn run_stealth_diagnostic(
1175 page: &crate::page::PageHandle,
1176 observed: crate::diagnostic::TransportObservations,
1177 ) -> Result<Value> {
1178 let report = page.verify_stealth_with_transport(Some(observed)).await?;
1179 serde_json::to_value(&report)
1180 .map_err(|e| BrowserError::ConfigError(format!("failed to serialize report: {e}")))
1181 }
1182
1183 async fn tool_browser_navigate(&self, args: &Value) -> Result<Value> {
1184 let session_id = Self::require_str(args, "session_id")?;
1185 let url = Self::require_str(args, "url")?;
1186 let timeout_secs = args
1187 .get("timeout_secs")
1188 .and_then(serde_json::Value::as_f64)
1189 .unwrap_or(30.0);
1190
1191 let (session_arc, attached_browser_arc, page_arc, _, reddit_profile) =
1192 self.session_runtime(&session_id).await?;
1193
1194 self.ensure_session_page(
1195 &session_id,
1196 &session_arc,
1197 &attached_browser_arc,
1198 &page_arc,
1199 None,
1200 Duration::from_secs_f64(timeout_secs),
1201 reddit_profile,
1202 )
1203 .await?;
1204
1205 let (challenge_detected, challenge_cleared, title) = {
1206 let mut page_guard = page_arc.lock().await;
1207 let page = page_guard.as_mut().ok_or_else(|| {
1208 BrowserError::ConfigError(format!("Session page unavailable: {session_id}"))
1209 })?;
1210
1211 let (challenge_detected, challenge_cleared) = Self::navigate_with_profile(
1212 page,
1213 &url,
1214 Duration::from_secs_f64(timeout_secs),
1215 reddit_profile,
1216 )
1217 .await?;
1218 let title = page.title().await.unwrap_or_default();
1219 drop(page_guard);
1220 (challenge_detected, challenge_cleared, title)
1221 };
1222
1223 let current_url = url.clone();
1224
1225 if let Some(session) = self.sessions.lock().await.get_mut(&session_id) {
1228 session.current_url = Some(current_url.clone());
1229 }
1230
1231 Ok(json!({
1232 "title": title,
1233 "url": current_url,
1234 "challenge_detected": challenge_detected,
1235 "challenge_cleared": challenge_cleared
1236 }))
1237 }
1238
1239 async fn tool_browser_eval(&self, args: &Value) -> Result<Value> {
1240 let session_id = Self::require_str(args, "session_id")?;
1241 let script = Self::require_str(args, "script")?;
1242 let timeout_secs = args
1243 .get("timeout_secs")
1244 .and_then(serde_json::Value::as_f64)
1245 .unwrap_or(30.0);
1246
1247 let (session_arc, attached_browser_arc, page_arc, nav_url_opt, reddit_profile) =
1248 self.session_runtime(&session_id).await?;
1249 let nav_url = nav_url_opt.ok_or_else(|| {
1250 BrowserError::ConfigError(
1251 "No page loaded — call browser_navigate before browser_eval".to_string(),
1252 )
1253 })?;
1254
1255 self.ensure_session_page(
1256 &session_id,
1257 &session_arc,
1258 &attached_browser_arc,
1259 &page_arc,
1260 Some(nav_url.as_str()),
1261 Duration::from_secs_f64(timeout_secs),
1262 reddit_profile,
1263 )
1264 .await?;
1265
1266 let mut page_guard = page_arc.lock().await;
1267 let page = page_guard.as_mut().ok_or_else(|| {
1268 BrowserError::ConfigError(format!("Session page unavailable: {session_id}"))
1269 })?;
1270 let result: Value = page.eval(&script).await?;
1271 drop(page_guard);
1272
1273 Ok(json!({ "result": result }))
1274 }
1275
1276 async fn tool_browser_screenshot(&self, args: &Value) -> Result<Value> {
1277 use base64::Engine as _;
1278 let session_id = Self::require_str(args, "session_id")?;
1279 let timeout_secs = args
1280 .get("timeout_secs")
1281 .and_then(serde_json::Value::as_f64)
1282 .unwrap_or(30.0);
1283
1284 let (session_arc, attached_browser_arc, page_arc, nav_url_opt, reddit_profile) =
1285 self.session_runtime(&session_id).await?;
1286 let nav_url = nav_url_opt.ok_or_else(|| {
1287 BrowserError::ConfigError(
1288 "No page loaded — call browser_navigate before browser_screenshot".to_string(),
1289 )
1290 })?;
1291
1292 self.ensure_session_page(
1293 &session_id,
1294 &session_arc,
1295 &attached_browser_arc,
1296 &page_arc,
1297 Some(nav_url.as_str()),
1298 Duration::from_secs_f64(timeout_secs),
1299 reddit_profile,
1300 )
1301 .await?;
1302
1303 let mut page_guard = page_arc.lock().await;
1304 let page = page_guard.as_mut().ok_or_else(|| {
1305 BrowserError::ConfigError(format!("Session page unavailable: {session_id}"))
1306 })?;
1307 let png_bytes = page.screenshot().await?;
1308 drop(page_guard);
1309
1310 let encoded = base64::engine::general_purpose::STANDARD.encode(&png_bytes);
1311 Ok(json!({ "data": encoded, "mimeType": "image/png", "bytes": png_bytes.len() }))
1312 }
1313
1314 async fn tool_browser_content(&self, args: &Value) -> Result<Value> {
1315 let session_id = Self::require_str(args, "session_id")?;
1316 let timeout_secs = args
1317 .get("timeout_secs")
1318 .and_then(serde_json::Value::as_f64)
1319 .unwrap_or(30.0);
1320
1321 let (session_arc, attached_browser_arc, page_arc, nav_url_opt, reddit_profile) =
1322 self.session_runtime(&session_id).await?;
1323 let nav_url = nav_url_opt.ok_or_else(|| {
1324 BrowserError::ConfigError(
1325 "No page loaded — call browser_navigate before browser_content".to_string(),
1326 )
1327 })?;
1328
1329 self.ensure_session_page(
1330 &session_id,
1331 &session_arc,
1332 &attached_browser_arc,
1333 &page_arc,
1334 Some(nav_url.as_str()),
1335 Duration::from_secs_f64(timeout_secs),
1336 reddit_profile,
1337 )
1338 .await?;
1339
1340 let mut page_guard = page_arc.lock().await;
1341 let page = page_guard.as_mut().ok_or_else(|| {
1342 BrowserError::ConfigError(format!("Session page unavailable: {session_id}"))
1343 })?;
1344 let html = page.content().await?;
1345 drop(page_guard);
1346
1347 Ok(json!({ "html": html, "bytes": html.len() }))
1348 }
1349
1350 #[cfg(feature = "mcp-attach")]
1351 async fn tool_browser_attach(&self, args: &Value) -> Result<Value> {
1352 let mode = Self::require_str(args, "mode")?;
1353 let endpoint = args
1354 .get("endpoint")
1355 .and_then(Value::as_str)
1356 .map(ToString::to_string);
1357 let profile_hint = args
1358 .get("profile_hint")
1359 .and_then(Value::as_str)
1360 .map(ToString::to_string);
1361
1362 let target_profile = args
1363 .get("target_profile")
1364 .and_then(Value::as_str)
1365 .map_or_else(
1366 || "default".to_string(),
1367 |s| {
1368 if s.eq_ignore_ascii_case("reddit") {
1369 "reddit".to_string()
1370 } else {
1371 "default".to_string()
1372 }
1373 },
1374 );
1375
1376 match mode.as_str() {
1377 "extension_bridge" => Ok(json!({
1378 "supported": false,
1379 "mode": mode,
1380 "profile_hint": profile_hint,
1381 "status": "not_implemented",
1382 "next_step": "Implement extension bridge handshake and profile transfer"
1383 })),
1384 "cdp_ws" => {
1385 let endpoint = endpoint.ok_or_else(|| {
1386 BrowserError::ConfigError("missing endpoint for cdp_ws mode".to_string())
1387 })?;
1388 if !(endpoint.starts_with("ws://") || endpoint.starts_with("wss://")) {
1389 return Err(BrowserError::ConfigError(
1390 "endpoint must start with ws:// or wss://".to_string(),
1391 ));
1392 }
1393
1394 let attach_timeout = Duration::from_secs(10);
1395 let (browser, mut handler) =
1396 tokio::time::timeout(attach_timeout, Browser::connect(endpoint.clone()))
1397 .await
1398 .map_err(|_| BrowserError::Timeout {
1399 operation: "Browser.connect".to_string(),
1400 duration_ms: 10_000,
1401 })?
1402 .map_err(|e| BrowserError::ConnectionError {
1403 url: endpoint.clone(),
1404 reason: e.to_string(),
1405 })?;
1406
1407 let handler_task = tokio::spawn(async move {
1408 while let Some(event) = handler.next().await {
1409 if let Err(error) = event {
1410 tracing::warn!(%error, "attached browser handler error");
1412 break;
1413 }
1414 }
1415 });
1416
1417 let session_id = Ulid::new().to_string();
1418 self.sessions.lock().await.insert(
1419 session_id.clone(),
1420 McpSession {
1421 handle: Arc::new(Mutex::new(None)),
1422 attached_browser: Arc::new(Mutex::new(Some(browser))),
1423 attached_handler_task: Arc::new(Mutex::new(Some(handler_task))),
1424 page: Arc::new(Mutex::new(None)),
1425 stealth_level: StealthLevel::None,
1426 tls_profile: None,
1427 webrtc_policy: None,
1428 cdp_fix_mode: None,
1429 proxy: None,
1430 target_profile: target_profile.clone(),
1431 current_url: None,
1432 saved_snapshot: None,
1433 attach_endpoint: Some(endpoint.clone()),
1434 behavior_plan: None,
1435 },
1436 );
1437
1438 Ok(json!({
1439 "supported": true,
1440 "mode": "cdp_ws",
1441 "session_id": session_id,
1442 "endpoint": endpoint,
1443 "profile_hint": profile_hint,
1444 "requested_metadata": {
1445 "target_profile": target_profile
1446 }
1447 }))
1448 }
1449 other => Err(BrowserError::ConfigError(format!(
1450 "Invalid mode '{other}'. Use one of: extension_bridge, cdp_ws"
1451 ))),
1452 }
1453 }
1454
1455 async fn tool_browser_auth_session(&self, args: &Value) -> Result<Value> {
1456 let session_id = Self::require_str(args, "session_id")?;
1457 let mode = Self::require_str(args, "mode")?;
1458 let file_path = args
1459 .get("file_path")
1460 .and_then(Value::as_str)
1461 .map(ToString::to_string);
1462 let ttl_secs = args.get("ttl_secs").and_then(Value::as_u64);
1463 let navigate_to_origin = args
1464 .get("navigate_to_origin")
1465 .and_then(Value::as_bool)
1466 .unwrap_or(true);
1467 let interaction_level = args
1468 .get("interaction_level")
1469 .and_then(Value::as_str)
1470 .unwrap_or("none")
1471 .to_string();
1472
1473 let payload = match mode.as_str() {
1474 "capture" => {
1475 let mut save_args = json!({
1476 "session_id": session_id,
1477 "include_snapshot": false
1478 });
1479 if let Some(ttl) = ttl_secs
1480 && let Some(obj) = save_args.as_object_mut()
1481 {
1482 obj.insert("ttl_secs".to_string(), Value::from(ttl));
1483 }
1484 if let Some(path) = file_path.clone()
1485 && let Some(obj) = save_args.as_object_mut()
1486 {
1487 obj.insert("file_path".to_string(), Value::String(path));
1488 }
1489
1490 let save = self.tool_browser_session_save(&save_args).await?;
1491
1492 let humanize = if interaction_level == "none" {
1493 None
1494 } else {
1495 let humanize_args = json!({
1496 "session_id": session_id,
1497 "level": interaction_level
1498 });
1499 Some(self.tool_browser_humanize(&humanize_args).await?)
1500 };
1501
1502 json!({
1503 "mode": "capture",
1504 "session_id": session_id,
1505 "save": save,
1506 "humanize": humanize
1507 })
1508 }
1509 "resume" => {
1510 let mut restore_args = json!({
1511 "session_id": session_id,
1512 "use_saved": file_path.is_none(),
1513 "navigate_to_origin": navigate_to_origin
1514 });
1515 if let Some(path) = file_path.clone()
1516 && let Some(obj) = restore_args.as_object_mut()
1517 {
1518 obj.insert("file_path".to_string(), Value::String(path));
1519 }
1520
1521 let restore = self.tool_browser_session_restore(&restore_args).await?;
1522
1523 let humanize = if interaction_level == "none" {
1524 None
1525 } else {
1526 let humanize_args = json!({
1527 "session_id": session_id,
1528 "level": interaction_level
1529 });
1530 Some(self.tool_browser_humanize(&humanize_args).await?)
1531 };
1532
1533 json!({
1534 "mode": "resume",
1535 "session_id": session_id,
1536 "restore": restore,
1537 "humanize": humanize
1538 })
1539 }
1540 other => {
1541 return Err(BrowserError::ConfigError(format!(
1542 "Invalid mode '{other}'. Use one of: capture, resume"
1543 )));
1544 }
1545 };
1546
1547 Ok(payload)
1548 }
1549
1550 async fn tool_browser_session_save(&self, args: &Value) -> Result<Value> {
1551 let session_id = Self::require_str(args, "session_id")?;
1552 let ttl_secs = args.get("ttl_secs").and_then(Value::as_u64);
1553 let file_path = args
1554 .get("file_path")
1555 .and_then(Value::as_str)
1556 .map(ToString::to_string);
1557 let include_snapshot = args
1558 .get("include_snapshot")
1559 .and_then(Value::as_bool)
1560 .unwrap_or(false);
1561
1562 let (session_arc, attached_browser_arc, page_arc, nav_url_opt, reddit_profile) =
1563 self.session_runtime(&session_id).await?;
1564
1565 self.ensure_session_page(
1566 &session_id,
1567 &session_arc,
1568 &attached_browser_arc,
1569 &page_arc,
1570 nav_url_opt.as_deref(),
1571 Duration::from_secs(30),
1572 reddit_profile,
1573 )
1574 .await?;
1575
1576 let mut snapshot = {
1577 let page_guard = page_arc.lock().await;
1578 let page = page_guard.as_ref().ok_or_else(|| {
1579 BrowserError::ConfigError(format!("Session page unavailable: {session_id}"))
1580 })?;
1581 let saved = save_session(page).await?;
1582 drop(page_guard);
1583 saved
1584 };
1585
1586 snapshot.ttl_secs = ttl_secs;
1587 if let Some(path) = &file_path {
1588 snapshot.save_to_file(path)?;
1589 }
1590
1591 let cookie_count = snapshot.cookies.len();
1592 let local_storage_keys = snapshot.local_storage.len();
1593 let origin = snapshot.origin.clone();
1594
1595 if let Some(session) = self.sessions.lock().await.get_mut(&session_id) {
1596 session.saved_snapshot = Some(snapshot.clone());
1597 }
1598
1599 let mut out = json!({
1600 "session_id": session_id,
1601 "origin": origin,
1602 "cookie_count": cookie_count,
1603 "local_storage_keys": local_storage_keys,
1604 "ttl_secs": ttl_secs,
1605 "saved_to_file": file_path
1606 });
1607
1608 if include_snapshot && let Some(obj) = out.as_object_mut() {
1609 obj.insert(
1610 "snapshot".to_string(),
1611 serde_json::to_value(&snapshot).map_err(|e| {
1612 BrowserError::ConfigError(format!("failed to serialize session snapshot: {e}"))
1613 })?,
1614 );
1615 }
1616
1617 Ok(out)
1618 }
1619
1620 async fn tool_browser_session_restore(&self, args: &Value) -> Result<Value> {
1621 let session_id = Self::require_str(args, "session_id")?;
1622 let file_path = args
1623 .get("file_path")
1624 .and_then(Value::as_str)
1625 .map(ToString::to_string);
1626 let use_saved = args
1627 .get("use_saved")
1628 .and_then(Value::as_bool)
1629 .unwrap_or(true);
1630 let navigate_to_origin = args
1631 .get("navigate_to_origin")
1632 .and_then(Value::as_bool)
1633 .unwrap_or(true);
1634
1635 let snapshot = if let Some(path) = file_path.as_deref() {
1636 SessionSnapshot::load_from_file(path)?
1637 } else if let Some(inline) = args.get("snapshot") {
1638 serde_json::from_value::<SessionSnapshot>(inline.clone()).map_err(|e| {
1639 BrowserError::ConfigError(format!("invalid inline session snapshot: {e}"))
1640 })?
1641 } else if use_saved {
1642 self.sessions
1643 .lock()
1644 .await
1645 .get(&session_id)
1646 .and_then(|s| s.saved_snapshot.clone())
1647 .ok_or_else(|| {
1648 BrowserError::ConfigError(
1649 "No saved session snapshot found for this session".to_string(),
1650 )
1651 })?
1652 } else {
1653 return Err(BrowserError::ConfigError(
1654 "No restore source provided. Set one of: file_path, snapshot, or use_saved=true"
1655 .to_string(),
1656 ));
1657 };
1658
1659 let source = if file_path.is_some() {
1660 "file"
1661 } else if args.get("snapshot").is_some() {
1662 "inline"
1663 } else {
1664 "saved"
1665 };
1666
1667 let (session_arc, attached_browser_arc, page_arc, nav_url_opt, reddit_profile) =
1668 self.session_runtime(&session_id).await?;
1669
1670 self.ensure_session_page(
1671 &session_id,
1672 &session_arc,
1673 &attached_browser_arc,
1674 &page_arc,
1675 nav_url_opt.as_deref(),
1676 Duration::from_secs(30),
1677 reddit_profile,
1678 )
1679 .await?;
1680
1681 {
1682 let mut page_guard = page_arc.lock().await;
1683 let page = page_guard.as_mut().ok_or_else(|| {
1684 BrowserError::ConfigError(format!("Session page unavailable: {session_id}"))
1685 })?;
1686
1687 if navigate_to_origin && !snapshot.origin.is_empty() {
1688 Self::navigate_with_profile(
1689 page,
1690 &snapshot.origin,
1691 Duration::from_secs(30),
1692 reddit_profile,
1693 )
1694 .await?;
1695 }
1696
1697 restore_session(page, &snapshot).await?;
1698 drop(page_guard);
1699 }
1700
1701 if let Some(session) = self.sessions.lock().await.get_mut(&session_id) {
1702 if !snapshot.origin.is_empty() {
1703 session.current_url = Some(snapshot.origin.clone());
1704 }
1705 session.saved_snapshot = Some(snapshot.clone());
1706 }
1707
1708 Ok(json!({
1709 "session_id": session_id,
1710 "source": source,
1711 "origin": snapshot.origin,
1712 "cookie_count": snapshot.cookies.len(),
1713 "local_storage_keys": snapshot.local_storage.len(),
1714 "snapshot_expired": snapshot.is_expired()
1715 }))
1716 }
1717
1718 async fn tool_browser_apply_behavior_json(&self, args: &Value) -> Result<Value> {
1719 let behavior = args.get("behavior").cloned().ok_or_else(|| {
1720 BrowserError::ConfigError("Missing required 'behavior' object".to_string())
1721 })?;
1722
1723 if !behavior.is_object() {
1724 return Err(BrowserError::ConfigError(
1725 "'behavior' must be a JSON object".to_string(),
1726 ));
1727 }
1728
1729 let adapter = PolymorphicBehaviorAdapter::from_json_value(behavior)?;
1730 let mut effective_config = BrowserConfig::default();
1731 let plan = adapter.apply(&mut effective_config);
1732 let adapter_kind = adapter.kind();
1733
1734 let session_id = args
1735 .get("session_id")
1736 .and_then(Value::as_str)
1737 .map(ToString::to_string);
1738
1739 let session_updated = if let Some(sid) = &session_id {
1740 let mut sessions = self.sessions.lock().await;
1741 let session = sessions
1742 .get_mut(sid)
1743 .ok_or_else(|| BrowserError::ConfigError(format!("Unknown session_id: {sid}")))?;
1744
1745 let cdp_fix_mode = serde_json::to_value(effective_config.cdp_fix_mode)
1746 .ok()
1747 .and_then(|value| value.as_str().map(ToString::to_string));
1748
1749 session.behavior_plan = Some(plan.clone());
1750 session.stealth_level = effective_config.stealth_level;
1751 session.cdp_fix_mode = cdp_fix_mode;
1752 session.proxy.clone_from(&effective_config.proxy);
1753
1754 #[cfg(feature = "stealth")]
1755 {
1756 session.webrtc_policy = Some(format!("{:?}", effective_config.webrtc.policy));
1757 }
1758
1759 drop(sessions);
1760 true
1761 } else {
1762 false
1763 };
1764
1765 let effective_view = json!({
1766 "headless": effective_config.headless,
1767 "stealth_level": effective_config.stealth_level,
1768 "proxy": effective_config.proxy,
1769 "window_size": effective_config.window_size,
1770 "cdp_fix_mode": effective_config.cdp_fix_mode,
1771 "args": effective_config.args
1772 });
1773
1774 Ok(json!({
1775 "adapter_kind": adapter_kind,
1776 "plan": plan,
1777 "effective_config": effective_view,
1778 "session_id": session_id,
1779 "session_updated": session_updated
1780 }))
1781 }
1782
1783 async fn tool_browser_humanize(&self, args: &Value) -> Result<Value> {
1784 let session_id = Self::require_str(args, "session_id")?;
1785 let default_level = {
1786 let sessions = self.sessions.lock().await;
1787 sessions
1788 .get(&session_id)
1789 .and_then(|s| s.behavior_plan.as_ref())
1790 .map_or(InteractionLevel::Low, |plan| match plan.interaction_level {
1791 BehaviorInteractionLevel::None => InteractionLevel::None,
1792 BehaviorInteractionLevel::Low => InteractionLevel::Low,
1793 BehaviorInteractionLevel::Medium => InteractionLevel::Medium,
1794 BehaviorInteractionLevel::High => InteractionLevel::High,
1795 })
1796 };
1797 let level = match args.get("level").and_then(Value::as_str) {
1798 Some("none") => InteractionLevel::None,
1799 Some("medium") => InteractionLevel::Medium,
1800 Some("high") => InteractionLevel::High,
1801 Some(_) => InteractionLevel::Low,
1802 None => default_level,
1803 };
1804 let viewport_width = args
1805 .get("viewport_width")
1806 .and_then(Value::as_f64)
1807 .unwrap_or(1366.0);
1808 let viewport_height = args
1809 .get("viewport_height")
1810 .and_then(Value::as_f64)
1811 .unwrap_or(768.0);
1812
1813 let (session_arc, attached_browser_arc, page_arc, nav_url_opt, reddit_profile) =
1814 self.session_runtime(&session_id).await?;
1815
1816 self.ensure_session_page(
1817 &session_id,
1818 &session_arc,
1819 &attached_browser_arc,
1820 &page_arc,
1821 nav_url_opt.as_deref(),
1822 Duration::from_secs(30),
1823 reddit_profile,
1824 )
1825 .await?;
1826
1827 {
1828 let page_guard = page_arc.lock().await;
1829 let page = page_guard.as_ref().ok_or_else(|| {
1830 BrowserError::ConfigError(format!("Session page unavailable: {session_id}"))
1831 })?;
1832
1833 let mut simulator = InteractionSimulator::new(level);
1834 simulator
1835 .random_interaction(page.inner(), viewport_width, viewport_height)
1836 .await?;
1837 drop(page_guard);
1838 }
1839
1840 let level_str = match level {
1841 InteractionLevel::None => "none",
1842 InteractionLevel::Low => "low",
1843 InteractionLevel::Medium => "medium",
1844 InteractionLevel::High => "high",
1845 };
1846
1847 Ok(json!({
1848 "session_id": session_id,
1849 "level": level_str,
1850 "viewport_width": viewport_width,
1851 "viewport_height": viewport_height,
1852 "applied": true
1853 }))
1854 }
1855
1856 async fn tool_browser_query(&self, args: &Value) -> Result<Value> {
1857 let session_id = Self::require_str(args, "session_id")?;
1858 let url = Self::require_str(args, "url")?;
1859 let selector = Self::require_str(args, "selector")?;
1860 let limit = usize::try_from(
1861 args.get("limit")
1862 .and_then(serde_json::Value::as_u64)
1863 .unwrap_or(50),
1864 )
1865 .unwrap_or(50);
1866 let timeout_secs = args
1867 .get("timeout_secs")
1868 .and_then(serde_json::Value::as_f64)
1869 .unwrap_or(30.0);
1870
1871 let fields: Option<Vec<(String, Option<String>)>> =
1873 args.get("fields").and_then(|v| v.as_object()).map(|obj| {
1874 obj.iter()
1875 .map(|(k, v)| {
1876 let attr = v
1877 .get("attr")
1878 .and_then(serde_json::Value::as_str)
1879 .map(ToString::to_string);
1880 (k.clone(), attr)
1881 })
1882 .collect()
1883 });
1884
1885 let (session_arc, attached_browser_arc, page_arc, _, reddit_profile) =
1886 self.session_runtime(&session_id).await?;
1887 self.ensure_session_page(
1888 &session_id,
1889 &session_arc,
1890 &attached_browser_arc,
1891 &page_arc,
1892 None,
1893 Duration::from_secs_f64(timeout_secs),
1894 reddit_profile,
1895 )
1896 .await?;
1897
1898 let mut page_guard = page_arc.lock().await;
1899 let page = page_guard.as_mut().ok_or_else(|| {
1900 BrowserError::ConfigError(format!("Session page unavailable: {session_id}"))
1901 })?;
1902
1903 Self::navigate_with_profile(
1904 page,
1905 &url,
1906 Duration::from_secs_f64(timeout_secs),
1907 reddit_profile,
1908 )
1909 .await?;
1910
1911 let all_nodes = page.query_selector_all(&selector).await?;
1912 let nodes = all_nodes.get(..limit).unwrap_or(&all_nodes);
1913 let mut results: Vec<Value> = Vec::with_capacity(nodes.len());
1914 if let Some(ref field_defs) = fields {
1915 for node in nodes {
1916 let mut obj = serde_json::Map::new();
1917 for (field_name, attr_name) in field_defs {
1918 let val = if let Some(attr) = attr_name {
1919 node.attr(attr)
1920 .await
1921 .map_or(Value::Null, |opt| opt.map_or(Value::Null, Value::String))
1922 } else {
1923 node.text_content().await.map_or(Value::Null, Value::String)
1924 };
1925 obj.insert(field_name.clone(), val);
1926 }
1927 results.push(Value::Object(obj));
1928 }
1929 } else {
1930 for node in nodes {
1931 let text = node.text_content().await.unwrap_or_default();
1932 results.push(Value::String(text));
1933 }
1934 }
1935 drop(page_guard);
1936 if let Some(session) = self.sessions.lock().await.get_mut(&session_id) {
1937 session.current_url = Some(url.clone());
1938 }
1939
1940 Ok(json!({
1941 "url": url,
1942 "selector": selector,
1943 "count": results.len(),
1944 "results": results
1945 }))
1946 }
1947
1948 async fn tool_browser_extract(&self, args: &Value) -> Result<Value> {
1949 let session_id = Self::require_str(args, "session_id")?;
1950 let url = Self::require_str(args, "url")?;
1951 let root_selector = Self::require_str(args, "root_selector")?;
1952 let timeout_secs = args
1953 .get("timeout_secs")
1954 .and_then(serde_json::Value::as_f64)
1955 .unwrap_or(30.0);
1956
1957 let schema_obj = args
1959 .get("schema")
1960 .and_then(|v| v.as_object())
1961 .ok_or_else(|| {
1962 BrowserError::ConfigError("Missing or non-object 'schema' argument".to_string())
1963 })?;
1964
1965 let schema: Vec<(String, ExtractFieldDef)> = schema_obj
1966 .iter()
1967 .filter_map(|(name, spec)| {
1968 let selector = spec
1969 .get("selector")
1970 .and_then(serde_json::Value::as_str)
1971 .map(ToString::to_string)?;
1972 let attr = spec
1973 .get("attr")
1974 .and_then(serde_json::Value::as_str)
1975 .map(ToString::to_string);
1976 let required = spec
1977 .get("required")
1978 .and_then(serde_json::Value::as_bool)
1979 .unwrap_or(false);
1980 Some((
1981 name.clone(),
1982 ExtractFieldDef {
1983 selector,
1984 attr,
1985 required,
1986 },
1987 ))
1988 })
1989 .collect();
1990
1991 let (session_arc, attached_browser_arc, page_arc, _, reddit_profile) =
1992 self.session_runtime(&session_id).await?;
1993 self.ensure_session_page(
1994 &session_id,
1995 &session_arc,
1996 &attached_browser_arc,
1997 &page_arc,
1998 None,
1999 Duration::from_secs_f64(timeout_secs),
2000 reddit_profile,
2001 )
2002 .await?;
2003
2004 let mut page_guard = page_arc.lock().await;
2005 let page = page_guard.as_mut().ok_or_else(|| {
2006 BrowserError::ConfigError(format!("Session page unavailable: {session_id}"))
2007 })?;
2008
2009 Self::navigate_with_profile(
2010 page,
2011 &url,
2012 Duration::from_secs_f64(timeout_secs),
2013 reddit_profile,
2014 )
2015 .await?;
2016
2017 let roots = page.query_selector_all(&root_selector).await?;
2018 let mut results: Vec<Value> = Vec::with_capacity(roots.len());
2019 for root in &roots {
2020 if let Some(obj) = Self::extract_record(root, &schema).await {
2021 results.push(Value::Object(obj));
2022 }
2023 }
2024 drop(page_guard);
2025 if let Some(session) = self.sessions.lock().await.get_mut(&session_id) {
2026 session.current_url = Some(url.clone());
2027 }
2028
2029 Ok(json!({
2030 "url": url,
2031 "root_selector": root_selector,
2032 "count": results.len(),
2033 "results": results
2034 }))
2035 }
2036
2037 #[cfg(feature = "similarity")]
2038 async fn tool_browser_find_similar(&self, args: &Value) -> Result<Value> {
2039 use crate::similarity::SimilarityConfig;
2040
2041 let session_id = Self::require_str(args, "session_id")?;
2042 let url = Self::require_str(args, "url")?;
2043 let reference_selector = Self::require_str(args, "reference_selector")?;
2044 #[allow(clippy::cast_possible_truncation)]
2045 let threshold = args
2046 .get("threshold")
2047 .and_then(serde_json::Value::as_f64)
2048 .map_or(SimilarityConfig::DEFAULT_THRESHOLD, |v| v as f32);
2049 let max_results = usize::try_from(
2050 args.get("max_results")
2051 .and_then(serde_json::Value::as_u64)
2052 .unwrap_or(10),
2053 )
2054 .unwrap_or(10);
2055 let timeout_secs = args
2056 .get("timeout_secs")
2057 .and_then(serde_json::Value::as_f64)
2058 .unwrap_or(30.0);
2059
2060 let config = SimilarityConfig {
2061 threshold,
2062 max_results,
2063 };
2064
2065 let (session_arc, attached_browser_arc, page_arc, _, reddit_profile) =
2066 self.session_runtime(&session_id).await?;
2067 self.ensure_session_page(
2068 &session_id,
2069 &session_arc,
2070 &attached_browser_arc,
2071 &page_arc,
2072 None,
2073 Duration::from_secs_f64(timeout_secs),
2074 reddit_profile,
2075 )
2076 .await?;
2077
2078 let mut page_guard = page_arc.lock().await;
2079 let page = page_guard.as_mut().ok_or_else(|| {
2080 BrowserError::ConfigError(format!("Session page unavailable: {session_id}"))
2081 })?;
2082
2083 Self::navigate_with_profile(
2084 page,
2085 &url,
2086 Duration::from_secs_f64(timeout_secs),
2087 reddit_profile,
2088 )
2089 .await?;
2090
2091 let refs = page.query_selector_all(&reference_selector).await?;
2093 let Some(reference) = refs.into_iter().next() else {
2094 return Ok(json!({
2095 "isError": true,
2096 "error": format!("Reference selector matched no elements: {reference_selector}")
2097 }));
2098 };
2099
2100 let ref_fp = reference.fingerprint().await?;
2101 let matches = page.find_similar(&reference, config).await?;
2102
2103 let mut match_results: Vec<Value> = Vec::with_capacity(matches.len());
2104 for m in &matches {
2105 let text = m.node.text_content().await.unwrap_or_default();
2106 let snippet = m.node.inner_html().await.unwrap_or_default();
2107 let snippet: String = snippet.chars().take(200).collect();
2108 match_results.push(json!({
2109 "score": m.score,
2110 "text": text,
2111 "outer_html_snippet": snippet
2112 }));
2113 }
2114 drop(page_guard);
2115 if let Some(session) = self.sessions.lock().await.get_mut(&session_id) {
2116 session.current_url = Some(url.clone());
2117 }
2118
2119 Ok(json!({
2120 "url": url,
2121 "reference": {
2122 "tag": ref_fp.tag,
2123 "classes": ref_fp.classes,
2124 "attr_names": ref_fp.attr_names,
2125 "depth": ref_fp.depth
2126 },
2127 "count": match_results.len(),
2128 "matches": match_results
2129 }))
2130 }
2131
2132 async fn tool_browser_warmup(&self, args: &Value) -> Result<Value> {
2133 use crate::page::{WarmupOptions, WarmupWait};
2134
2135 let session_id = Self::require_str(args, "session_id")?;
2136 let url = Self::require_str(args, "url")?;
2137 let wait = match args
2138 .get("wait")
2139 .and_then(|v| v.as_str())
2140 .unwrap_or("dom_content_loaded")
2141 {
2142 "network_idle" => WarmupWait::NetworkIdle,
2143 _ => WarmupWait::DomContentLoaded,
2144 };
2145 let timeout_ms = args
2146 .get("timeout_ms")
2147 .and_then(serde_json::Value::as_u64)
2148 .unwrap_or(30_000);
2149 let stabilize_ms = args
2150 .get("stabilize_ms")
2151 .and_then(serde_json::Value::as_u64)
2152 .unwrap_or(0);
2153
2154 let (session_arc, attached_browser_arc, page_arc, _, _) =
2155 self.session_runtime(&session_id).await?;
2156 self.ensure_session_page(
2157 &session_id,
2158 &session_arc,
2159 &attached_browser_arc,
2160 &page_arc,
2161 None,
2162 Duration::from_millis(timeout_ms),
2163 false,
2164 )
2165 .await?;
2166
2167 let mut page_guard = page_arc.lock().await;
2168 let page = page_guard.as_mut().ok_or_else(|| {
2169 BrowserError::ConfigError(format!("Session page unavailable: {session_id}"))
2170 })?;
2171
2172 let report = page
2173 .warmup(WarmupOptions {
2174 url,
2175 wait,
2176 timeout_ms,
2177 stabilize_ms,
2178 })
2179 .await?;
2180 drop(page_guard);
2181
2182 Ok(json!({
2183 "session_id": session_id,
2184 "url": report.url,
2185 "elapsed_ms": report.elapsed_ms,
2186 "status_code": report.status_code,
2187 "title": report.title,
2188 "stabilized": report.stabilized
2189 }))
2190 }
2191
2192 async fn tool_browser_refresh(&self, args: &Value) -> Result<Value> {
2193 use crate::page::{RefreshOptions, WarmupWait};
2194
2195 let session_id = Self::require_str(args, "session_id")?;
2196 let wait = match args
2197 .get("wait")
2198 .and_then(|v| v.as_str())
2199 .unwrap_or("dom_content_loaded")
2200 {
2201 "network_idle" => WarmupWait::NetworkIdle,
2202 _ => WarmupWait::DomContentLoaded,
2203 };
2204 let timeout_ms = args
2205 .get("timeout_ms")
2206 .and_then(serde_json::Value::as_u64)
2207 .unwrap_or(30_000);
2208 let reset_connection = args
2209 .get("reset_connection")
2210 .and_then(serde_json::Value::as_bool)
2211 .unwrap_or(false);
2212
2213 let (session_arc, attached_browser_arc, page_arc, _, _) =
2214 self.session_runtime(&session_id).await?;
2215 self.ensure_session_page(
2216 &session_id,
2217 &session_arc,
2218 &attached_browser_arc,
2219 &page_arc,
2220 None,
2221 Duration::from_millis(timeout_ms),
2222 false,
2223 )
2224 .await?;
2225
2226 let mut page_guard = page_arc.lock().await;
2227 let page = page_guard.as_mut().ok_or_else(|| {
2228 BrowserError::ConfigError(format!("Session page unavailable: {session_id}"))
2229 })?;
2230
2231 let report = page
2232 .refresh(RefreshOptions {
2233 wait,
2234 timeout_ms,
2235 reset_connection,
2236 })
2237 .await?;
2238 drop(page_guard);
2239
2240 Ok(json!({
2241 "session_id": session_id,
2242 "url": report.url,
2243 "elapsed_ms": report.elapsed_ms,
2244 "status_code": report.status_code
2245 }))
2246 }
2247
2248 async fn tool_browser_release(&self, args: &Value) -> Result<Value> {
2249 let session_id = Self::require_str(args, "session_id")?;
2250
2251 let (session_arc, attached_browser_arc, attached_handler_task_arc, page_arc) = {
2253 let mut sessions = self.sessions.lock().await;
2254 let removed = sessions.remove(&session_id).ok_or_else(|| {
2255 BrowserError::ConfigError(format!("Unknown session: {session_id}"))
2256 })?;
2257 drop(sessions);
2258 (
2259 removed.handle,
2260 removed.attached_browser,
2261 removed.attached_handler_task,
2262 removed.page,
2263 )
2264 };
2265
2266 let handle = session_arc.lock().await.take();
2268 if let Some(h) = handle {
2269 h.release().await;
2270 }
2271
2272 let attached_browser = attached_browser_arc.lock().await.take();
2273 if let Some(mut browser) = attached_browser {
2274 let close_timeout = Duration::from_secs(5);
2275 match tokio::time::timeout(close_timeout, browser.close()).await {
2276 Ok(Ok(_)) => {}
2277 Ok(Err(error)) => {
2278 tracing::warn!(%session_id, %error, "attached browser close failed during release");
2279 }
2280 Err(_) => {
2281 tracing::warn!(%session_id, "attached browser close timed out during release");
2282 }
2283 }
2284 }
2285
2286 let attached_handler_task = attached_handler_task_arc.lock().await.take();
2287 if let Some(task) = attached_handler_task {
2288 task.abort();
2289 }
2290
2291 let page = page_arc.lock().await.take();
2292 if let Some(page) = page {
2293 page.close().await.ok();
2294 }
2295
2296 info!(%session_id, "MCP session released");
2297 Ok(json!({ "released": true, "session_id": session_id }))
2298 }
2299
2300 #[cfg(feature = "stealth")]
2301 async fn tool_browser_validate_stealth(&self, args: &Value) -> Result<Value> {
2302 use crate::validation::{ValidationResult, ValidationSuite, ValidationTarget};
2303
2304 let tier1_only = args
2305 .get("tier1_only")
2306 .and_then(Value::as_bool)
2307 .unwrap_or(false);
2308 let timeout_secs = args
2309 .get("timeout_secs")
2310 .and_then(Value::as_u64)
2311 .unwrap_or(30);
2312
2313 let targets = if tier1_only {
2315 ValidationTarget::tier1().to_vec()
2316 } else {
2317 args.get("targets").and_then(|v| v.as_array()).map_or_else(
2318 || ValidationTarget::tier1().to_vec(),
2319 |arr| {
2320 arr.iter()
2321 .filter_map(|v| v.as_str())
2322 .filter_map(|s| match s {
2323 "creepjs" => Some(ValidationTarget::CreepJs),
2324 "browserscan" => Some(ValidationTarget::BrowserScan),
2325 "fingerprint_js" => Some(ValidationTarget::FingerprintJs),
2326 "kasada" => Some(ValidationTarget::Kasada),
2327 "cloudflare" => Some(ValidationTarget::Cloudflare),
2328 "akamai" => Some(ValidationTarget::Akamai),
2329 "data_dome" => Some(ValidationTarget::DataDome),
2330 "perimeter_x" => Some(ValidationTarget::PerimeterX),
2331 _ => None,
2332 })
2333 .collect::<Vec<_>>()
2334 },
2335 )
2336 };
2337
2338 let mut results = Vec::with_capacity(targets.len());
2340 for target in targets {
2341 let timed = tokio::time::timeout(
2342 Duration::from_secs(timeout_secs),
2343 ValidationSuite::run_one(&self.pool, target),
2344 )
2345 .await;
2346 match timed {
2347 Ok(result) => results.push(result),
2348 Err(_) => results.push(ValidationResult::failed(
2349 target,
2350 &format!("validation timed out after {timeout_secs}s"),
2351 )),
2352 }
2353 }
2354
2355 serde_json::to_value(&results)
2357 .map_err(|e| BrowserError::ConfigError(format!("failed to serialize results: {e}")))
2358 }
2359
2360 fn tool_pool_stats(&self) -> Value {
2361 let stats = self.pool.stats();
2362 json!({
2363 "active": stats.active,
2364 "max": stats.max,
2365 "available": stats.available
2366 })
2367 }
2368
2369 async fn handle_resources_list(&self, id: Value) -> JsonRpcResponse {
2372 let resources: Vec<Value> = self
2373 .sessions
2374 .lock()
2375 .await
2376 .keys()
2377 .map(|sid| {
2378 json!({
2379 "uri": format!("browser://session/{sid}"),
2380 "name": format!("Browser session {sid}"),
2381 "mimeType": "application/json"
2382 })
2383 })
2384 .collect();
2385
2386 JsonRpcResponse::ok(id, json!({ "resources": resources }))
2387 }
2388
2389 async fn handle_resources_read(&self, id: Value, params: Value) -> JsonRpcResponse {
2392 let uri = match params.get("uri").and_then(|v| v.as_str()) {
2393 Some(u) => u.to_string(),
2394 None => return JsonRpcResponse::err(id, -32602, "Missing 'uri'"),
2395 };
2396
2397 let session_id = uri
2399 .strip_prefix("browser://session/")
2400 .unwrap_or("")
2401 .to_string();
2402
2403 let session_config: Option<Value> = {
2405 let sessions = self.sessions.lock().await;
2406 sessions.get(&session_id).map(|s| {
2407 json!({
2408 "stealth_level": format!("{:?}", s.stealth_level).to_lowercase(),
2409 "tls_profile": s.tls_profile,
2410 "webrtc_policy": s.webrtc_policy,
2411 "cdp_fix_mode": s.cdp_fix_mode,
2412 "proxy": s.proxy,
2413 "target_profile": s.target_profile,
2414 "current_url": s.current_url,
2415 "has_saved_snapshot": s.saved_snapshot.is_some(),
2416 "attach_endpoint": s.attach_endpoint,
2417 "has_behavior_plan": s.behavior_plan.is_some(),
2418 "behavior_plan": s.behavior_plan.as_ref()
2419 })
2420 })
2421 };
2422
2423 if let Some(config) = session_config {
2424 let pool_stats = self.pool.stats();
2425 JsonRpcResponse::ok(
2426 id,
2427 json!({
2428 "contents": [{
2429 "uri": uri,
2430 "mimeType": "application/json",
2431 "text": serde_json::to_string_pretty(&json!({
2432 "session_id": session_id,
2433 "config": config,
2434 "pool_active": pool_stats.active,
2435 "pool_max": pool_stats.max
2436 })).unwrap_or_default()
2437 }]
2438 }),
2439 )
2440 } else {
2441 JsonRpcResponse::err(id, -32002, format!("Resource not found: {uri}"))
2442 }
2443 }
2444
2445 async fn session_runtime(
2448 &self,
2449 session_id: &str,
2450 ) -> Result<(
2451 Arc<Mutex<Option<BrowserHandle>>>,
2452 Arc<Mutex<Option<Browser>>>,
2453 Arc<Mutex<Option<crate::page::PageHandle>>>,
2454 Option<String>,
2455 bool,
2456 )> {
2457 self.sessions
2458 .lock()
2459 .await
2460 .get(session_id)
2461 .map(|s| {
2462 (
2463 s.handle.clone(),
2464 s.attached_browser.clone(),
2465 s.page.clone(),
2466 s.current_url.clone(),
2467 s.target_profile == "reddit",
2468 )
2469 })
2470 .ok_or_else(|| BrowserError::ConfigError(format!("Unknown session: {session_id}")))
2471 }
2472
2473 #[expect(
2474 clippy::too_many_arguments,
2475 reason = "session runtime handles and bootstrap options are passed explicitly for clarity"
2476 )]
2477 async fn ensure_session_page(
2478 &self,
2479 session_id: &str,
2480 handle_arc: &Arc<Mutex<Option<BrowserHandle>>>,
2481 attached_browser_arc: &Arc<Mutex<Option<Browser>>>,
2482 page_arc: &Arc<Mutex<Option<crate::page::PageHandle>>>,
2483 current_url: Option<&str>,
2484 timeout: Duration,
2485 reddit_profile: bool,
2486 ) -> Result<()> {
2487 let mut page_guard = page_arc.lock().await;
2488 let created = if page_guard.is_none() {
2489 let new_page =
2490 Self::create_session_page(session_id, handle_arc, attached_browser_arc).await?;
2491
2492 *page_guard = Some(new_page);
2493 true
2494 } else {
2495 false
2496 };
2497
2498 if created
2499 && let Some(url) = current_url
2500 && let Some(page) = page_guard.as_mut()
2501 {
2502 Self::navigate_with_profile(page, url, timeout, reddit_profile).await?;
2503 }
2504
2505 drop(page_guard);
2506
2507 Ok(())
2508 }
2509
2510 async fn create_session_page(
2511 session_id: &str,
2512 handle_arc: &Arc<Mutex<Option<BrowserHandle>>>,
2513 attached_browser_arc: &Arc<Mutex<Option<Browser>>>,
2514 ) -> Result<crate::page::PageHandle> {
2515 let handle_guard = handle_arc.lock().await;
2516 if let Some(handle) = handle_guard.as_ref() {
2517 let browser = handle.browser().ok_or_else(|| {
2519 BrowserError::ConfigError(format!("Browser handle invalid: {session_id}"))
2520 })?;
2521 let page = browser.new_page().await?;
2522 drop(handle_guard);
2523 return Ok(page);
2524 }
2525 drop(handle_guard);
2526
2527 let browser_guard = attached_browser_arc.lock().await;
2528 let browser = browser_guard.as_ref().ok_or_else(|| {
2529 BrowserError::ConfigError(format!("Session already released: {session_id}"))
2530 })?;
2531 let raw_page =
2532 browser
2533 .new_page("about:blank")
2534 .await
2535 .map_err(|e| BrowserError::CdpError {
2536 operation: "Browser.newPage".to_string(),
2537 message: e.to_string(),
2538 })?;
2539 drop(browser_guard);
2540
2541 Ok(crate::page::PageHandle::new(
2542 raw_page,
2543 Duration::from_secs(30),
2544 ))
2545 }
2546
2547 async fn navigate_with_profile(
2548 page: &mut crate::page::PageHandle,
2549 url: &str,
2550 timeout: Duration,
2551 reddit_profile: bool,
2552 ) -> Result<(bool, bool)> {
2553 let wait_until = if reddit_profile {
2554 WaitUntil::DomContentLoaded
2555 } else {
2556 WaitUntil::Selector("body".to_string())
2557 };
2558
2559 page.navigate(url, wait_until, timeout).await?;
2560
2561 if reddit_profile || url.contains("reddit.com") {
2562 return Self::wait_for_reddit_challenge(page, timeout).await;
2563 }
2564
2565 Ok((false, true))
2566 }
2567
2568 async fn wait_for_reddit_challenge(
2569 page: &crate::page::PageHandle,
2570 timeout: Duration,
2571 ) -> Result<(bool, bool)> {
2572 let max_wait = timeout.min(Duration::from_secs(15));
2573 let mut elapsed = Duration::ZERO;
2574 let interval = Duration::from_millis(500);
2575 let mut challenge_seen = false;
2576
2577 while elapsed <= max_wait {
2578 let challenge_state = page
2579 .eval::<Value>(
2580 r#"(() => {
2581 const title = (document.title || "").toLowerCase();
2582 const href = (location.href || "").toLowerCase();
2583 const body = (document.body?.innerText || "").toLowerCase();
2584 const challenge =
2585 title.includes("verification") ||
2586 title.includes("just a moment") ||
2587 href.includes("/js_challenge") ||
2588 body.includes("please wait for verification") ||
2589 body.includes("verify you are human");
2590 return {
2591 challenge,
2592 ready: document.readyState === "complete"
2593 };
2594 })()"#,
2595 )
2596 .await
2597 .unwrap_or_else(|_| json!({"challenge": false, "ready": true}));
2598
2599 let is_challenge = challenge_state
2600 .get("challenge")
2601 .and_then(Value::as_bool)
2602 .unwrap_or(false);
2603 let ready = challenge_state
2604 .get("ready")
2605 .and_then(Value::as_bool)
2606 .unwrap_or(true);
2607
2608 challenge_seen |= is_challenge;
2609 if !is_challenge && ready {
2610 return Ok((challenge_seen, true));
2611 }
2612
2613 sleep(interval).await;
2614 elapsed += interval;
2615 }
2616
2617 Ok((challenge_seen, false))
2618 }
2619
2620 #[cfg(feature = "stealth")]
2621 async fn session_handle_and_stealth(
2622 &self,
2623 session_id: &str,
2624 ) -> Result<(Arc<Mutex<Option<BrowserHandle>>>, String)> {
2625 self.sessions
2626 .lock()
2627 .await
2628 .get(session_id)
2629 .map(|s| {
2630 (
2631 s.handle.clone(),
2632 format!("{:?}", s.stealth_level).to_lowercase(),
2633 )
2634 })
2635 .ok_or_else(|| BrowserError::ConfigError(format!("Unknown session: {session_id}")))
2636 }
2637
2638 async fn tool_browser_extract_with_fallback(&self, args: &Value) -> Result<Value> {
2642 let session_id = Self::require_str(args, "session_id")?;
2643 let url = Self::require_str(args, "url")?;
2644 let timeout_secs = args
2645 .get("timeout_secs")
2646 .and_then(serde_json::Value::as_f64)
2647 .unwrap_or(30.0);
2648 let selectors = Self::parse_root_selectors(args)?;
2649 let schema = Self::parse_extract_schema(args)?;
2650
2651 let (session_arc, attached_browser_arc, page_arc, _, reddit_profile) =
2652 self.session_runtime(&session_id).await?;
2653 self.ensure_session_page(
2654 &session_id,
2655 &session_arc,
2656 &attached_browser_arc,
2657 &page_arc,
2658 None,
2659 Duration::from_secs_f64(timeout_secs),
2660 reddit_profile,
2661 )
2662 .await?;
2663
2664 let mut page_guard = page_arc.lock().await;
2665 let page = page_guard.as_mut().ok_or_else(|| {
2666 BrowserError::ConfigError(format!("Session page unavailable: {session_id}"))
2667 })?;
2668
2669 Self::navigate_with_profile(
2670 page,
2671 &url,
2672 Duration::from_secs_f64(timeout_secs),
2673 reddit_profile,
2674 )
2675 .await?;
2676
2677 let mut matched_selector = String::new();
2678 let mut results: Vec<Value> = vec![];
2679
2680 for selector in &selectors {
2681 let roots = page.query_selector_all(selector).await?;
2682 if roots.is_empty() {
2683 continue;
2684 }
2685
2686 let mut selector_results: Vec<Value> = Vec::with_capacity(roots.len());
2687 for root in &roots {
2688 if let Some(obj) = Self::extract_record(root, &schema).await {
2689 selector_results.push(Value::Object(obj));
2690 }
2691 }
2692
2693 if selector_results.is_empty() {
2694 continue;
2695 }
2696
2697 matched_selector = selector.clone();
2698 results = selector_results;
2699 break;
2700 }
2701 drop(page_guard);
2702 if let Some(session) = self.sessions.lock().await.get_mut(&session_id) {
2703 session.current_url = Some(url.clone());
2704 }
2705
2706 Ok(json!({
2707 "url": url,
2708 "matched_selector": matched_selector,
2709 "tried_selectors": selectors,
2710 "count": results.len(),
2711 "results": results
2712 }))
2713 }
2714
2715 async fn tool_browser_extract_resilient(&self, args: &Value) -> Result<Value> {
2720 let session_id = Self::require_str(args, "session_id")?;
2721 let url = Self::require_str(args, "url")?;
2722 let root_selector = Self::require_str(args, "root_selector")?;
2723 let timeout_secs = args
2724 .get("timeout_secs")
2725 .and_then(serde_json::Value::as_f64)
2726 .unwrap_or(30.0);
2727 let schema = Self::parse_extract_schema(args)?;
2728
2729 let (session_arc, attached_browser_arc, page_arc, _, reddit_profile) =
2730 self.session_runtime(&session_id).await?;
2731 self.ensure_session_page(
2732 &session_id,
2733 &session_arc,
2734 &attached_browser_arc,
2735 &page_arc,
2736 None,
2737 Duration::from_secs_f64(timeout_secs),
2738 reddit_profile,
2739 )
2740 .await?;
2741
2742 let mut page_guard = page_arc.lock().await;
2743 let page = page_guard.as_mut().ok_or_else(|| {
2744 BrowserError::ConfigError(format!("Session page unavailable: {session_id}"))
2745 })?;
2746
2747 Self::navigate_with_profile(
2748 page,
2749 &url,
2750 Duration::from_secs_f64(timeout_secs),
2751 reddit_profile,
2752 )
2753 .await?;
2754
2755 let roots = page.query_selector_all(&root_selector).await?;
2756 let mut results: Vec<Value> = Vec::with_capacity(roots.len());
2759 let mut skipped: usize = 0;
2760 for root in &roots {
2761 match Self::extract_record(root, &schema).await {
2762 Some(obj) => results.push(Value::Object(obj)),
2763 None => skipped += 1,
2764 }
2765 }
2766 drop(page_guard);
2767 if let Some(session) = self.sessions.lock().await.get_mut(&session_id) {
2768 session.current_url = Some(url.clone());
2769 }
2770
2771 Ok(json!({
2772 "url": url,
2773 "root_selector": root_selector,
2774 "count": results.len(),
2775 "skipped": skipped,
2776 "results": results
2777 }))
2778 }
2779
2780 async fn extract_record(
2781 root: &crate::page::NodeHandle,
2782 schema: &[(String, ExtractFieldDef)],
2783 ) -> Option<serde_json::Map<String, Value>> {
2784 let mut obj = serde_json::Map::new();
2785 for (field_name, def) in schema {
2786 let Ok(children) = root.children_matching(&def.selector).await else {
2787 if def.required {
2788 return None;
2789 }
2790 obj.insert(field_name.clone(), Value::Null);
2791 continue;
2792 };
2793 let val = match children.into_iter().next() {
2794 None => {
2795 if def.required {
2796 return None;
2797 }
2798 Value::Null
2799 }
2800 Some(node) => {
2801 if let Some(attr) = &def.attr {
2802 node.attr(attr)
2803 .await
2804 .map_or(Value::Null, |opt| opt.map_or(Value::Null, Value::String))
2805 } else {
2806 node.text_content().await.map_or(Value::Null, Value::String)
2807 }
2808 }
2809 };
2810 obj.insert(field_name.clone(), val);
2811 }
2812 Some(obj)
2813 }
2814
2815 fn require_str(args: &Value, key: &str) -> Result<String> {
2816 args.get(key)
2817 .and_then(|v| v.as_str())
2818 .map(ToString::to_string)
2819 .ok_or_else(|| BrowserError::ConfigError(format!("Missing required argument: {key}")))
2820 }
2821
2822 fn parse_acquisition_mode(mode: &str) -> Result<AcquisitionMode> {
2823 match mode {
2824 "fast" => Ok(AcquisitionMode::Fast),
2825 "resilient" => Ok(AcquisitionMode::Resilient),
2826 "hostile" => Ok(AcquisitionMode::Hostile),
2827 "investigate" => Ok(AcquisitionMode::Investigate),
2828 other => Err(BrowserError::ConfigError(format!(
2829 "Invalid mode '{other}'. Use one of: fast, resilient, hostile, investigate"
2830 ))),
2831 }
2832 }
2833
2834 fn parse_acquisition_request(args: &Value) -> Result<AcquisitionRequest> {
2835 const MAX_ACQUISITION_TIMEOUT_SECS: f64 = 86_400.0;
2836
2837 let url = Self::require_str(args, "url")?;
2838 let mode_raw = Self::require_str(args, "mode")?;
2839 let mode = Self::parse_acquisition_mode(&mode_raw)?;
2840
2841 let wait_for_selector = args
2842 .get("wait_for_selector")
2843 .or_else(|| args.get("selector_wait"))
2844 .and_then(Value::as_str)
2845 .map(ToString::to_string);
2846
2847 let extraction_js = args
2848 .get("extraction_js")
2849 .and_then(Value::as_str)
2850 .map(ToString::to_string);
2851
2852 let browserbase_enabled = args
2853 .get("browserbase_enabled")
2854 .or_else(|| args.get("use_browserbase"))
2855 .and_then(Value::as_bool)
2856 .unwrap_or(false);
2857
2858 let total_timeout = match args.get("total_timeout_secs").and_then(Value::as_f64) {
2859 Some(value)
2860 if value.is_finite() && value > 0.0 && value <= MAX_ACQUISITION_TIMEOUT_SECS =>
2861 {
2862 Duration::from_secs_f64(value)
2863 }
2864 Some(_) => {
2865 return Err(BrowserError::ConfigError(format!(
2866 "total_timeout_secs must be a positive finite number <= {MAX_ACQUISITION_TIMEOUT_SECS}"
2867 )));
2868 }
2869 None => AcquisitionRequest::default().total_timeout,
2870 };
2871
2872 Ok(AcquisitionRequest {
2873 url,
2874 mode,
2875 wait_for_selector,
2876 extraction_js,
2877 total_timeout,
2878 browserbase_enabled,
2879 ..AcquisitionRequest::default()
2880 })
2881 }
2882
2883 fn acquisition_result_to_tool_output(result: &AcquisitionResult) -> Value {
2884 let strategy_used = serde_json::to_value(result.strategy_used).unwrap_or(Value::Null);
2885 let attempted = serde_json::to_value(&result.attempted).unwrap_or(Value::Array(Vec::new()));
2886 let failures = serde_json::to_value(&result.failures).unwrap_or(Value::Array(Vec::new()));
2887 let freshness = serde_json::to_value(&result.freshness).unwrap_or(Value::Null);
2888
2889 json!({
2890 "success": result.success,
2891 "strategy_used": strategy_used,
2892 "final_url": result.final_url,
2893 "status_code": result.status_code,
2894 "extracted": result.extracted,
2895 "html_excerpt": result.html_excerpt,
2896 "freshness": freshness,
2897 "diagnostics": {
2898 "attempted": attempted,
2899 "timed_out": result.timed_out,
2900 "failure_count": result.failures.len(),
2901 "failures": failures
2902 }
2903 })
2904 }
2905
2906 fn parse_root_selectors(args: &Value) -> Result<Vec<String>> {
2907 let selectors: Vec<String> = args
2908 .get("root_selectors")
2909 .and_then(Value::as_array)
2910 .ok_or_else(|| {
2911 BrowserError::ConfigError(
2912 "Missing or non-array 'root_selectors' argument".to_string(),
2913 )
2914 })?
2915 .iter()
2916 .filter_map(|v| v.as_str().map(str::to_string))
2917 .collect();
2918
2919 if selectors.is_empty() {
2920 return Err(BrowserError::ConfigError(
2921 "root_selectors must contain at least one entry".to_string(),
2922 ));
2923 }
2924 Ok(selectors)
2925 }
2926
2927 fn parse_extract_schema(args: &Value) -> Result<Vec<(String, ExtractFieldDef)>> {
2928 let schema_obj = args
2929 .get("schema")
2930 .and_then(Value::as_object)
2931 .ok_or_else(|| {
2932 BrowserError::ConfigError("Missing or non-object 'schema' argument".to_string())
2933 })?;
2934
2935 Ok(schema_obj
2936 .iter()
2937 .filter_map(|(name, spec)| {
2938 let selector = spec
2939 .get("selector")
2940 .and_then(Value::as_str)
2941 .map(ToString::to_string)?;
2942 let attr = spec
2943 .get("attr")
2944 .and_then(Value::as_str)
2945 .map(ToString::to_string);
2946 let required = spec
2947 .get("required")
2948 .and_then(Value::as_bool)
2949 .unwrap_or(false);
2950 Some((
2951 name.clone(),
2952 ExtractFieldDef {
2953 selector,
2954 attr,
2955 required,
2956 },
2957 ))
2958 })
2959 .collect())
2960 }
2961}
2962
2963fn mcp_enabled_from(value: &str) -> bool {
2966 matches!(value.to_lowercase().as_str(), "true" | "1" | "yes")
2967}
2968
2969#[must_use]
2974pub fn is_mcp_enabled() -> bool {
2975 mcp_enabled_from(&std::env::var("STYGIAN_MCP_ENABLED").unwrap_or_default())
2976}
2977
2978#[cfg(test)]
2979mod tests {
2980 use super::*;
2981
2982 #[test]
2983 fn tool_defs_include_browser_query() {
2984 let defs = &*TOOL_DEFINITIONS;
2985 assert!(
2986 defs.iter()
2987 .any(|t| t.get("name").and_then(|n| n.as_str()) == Some("browser_query")),
2988 "TOOL_DEFINITIONS must contain browser_query"
2989 );
2990 }
2991
2992 #[test]
2993 fn tool_defs_include_browser_extract() {
2994 let defs = &*TOOL_DEFINITIONS;
2995 assert!(
2996 defs.iter()
2997 .any(|t| t.get("name").and_then(|n| n.as_str()) == Some("browser_extract")),
2998 "TOOL_DEFINITIONS must contain browser_extract"
2999 );
3000 }
3001
3002 #[test]
3003 fn tool_defs_include_browser_acquire_and_extract() {
3004 let defs = &*TOOL_DEFINITIONS;
3005 assert!(
3006 defs.iter()
3007 .any(|t| t.get("name").and_then(|n| n.as_str())
3008 == Some("browser_acquire_and_extract")),
3009 "TOOL_DEFINITIONS must contain browser_acquire_and_extract"
3010 );
3011 }
3012
3013 #[test]
3014 fn tool_defs_include_browser_extract_with_fallback() {
3015 let defs = &*TOOL_DEFINITIONS;
3016 assert!(
3017 defs.iter()
3018 .any(|t| t.get("name").and_then(|n| n.as_str())
3019 == Some("browser_extract_with_fallback")),
3020 "TOOL_DEFINITIONS must contain browser_extract_with_fallback"
3021 );
3022 }
3023
3024 #[test]
3025 fn tool_defs_include_browser_extract_resilient() {
3026 let defs = &*TOOL_DEFINITIONS;
3027 assert!(
3028 defs.iter().any(
3029 |t| t.get("name").and_then(|n| n.as_str()) == Some("browser_extract_resilient")
3030 ),
3031 "TOOL_DEFINITIONS must contain browser_extract_resilient"
3032 );
3033 }
3034
3035 #[test]
3036 fn browser_extract_with_fallback_requires_root_selectors()
3037 -> std::result::Result<(), Box<dyn std::error::Error>> {
3038 let defs = &*TOOL_DEFINITIONS;
3039 let def = defs
3040 .iter()
3041 .find(|t| {
3042 t.get("name").and_then(|n| n.as_str()) == Some("browser_extract_with_fallback")
3043 })
3044 .ok_or("browser_extract_with_fallback must be in TOOL_DEFINITIONS")?;
3045 let required = def
3046 .get("inputSchema")
3047 .and_then(|s| s.get("required"))
3048 .and_then(Value::as_array)
3049 .ok_or("browser_extract_with_fallback inputSchema missing 'required' array")?;
3050 assert!(
3051 required.iter().any(|v| v == "root_selectors"),
3052 "root_selectors must be required in browser_extract_with_fallback"
3053 );
3054 Ok(())
3055 }
3056
3057 #[test]
3058 fn browser_query_required_args() -> std::result::Result<(), Box<dyn std::error::Error>> {
3059 let defs = &*TOOL_DEFINITIONS;
3061 let def = defs
3062 .iter()
3063 .find(|t| t.get("name").and_then(|n| n.as_str()) == Some("browser_query"))
3064 .ok_or("browser_query must be in TOOL_DEFINITIONS")?;
3065 let required = def
3066 .get("inputSchema")
3067 .and_then(|s| s.get("required"))
3068 .ok_or("browser_query inputSchema missing 'required'")?;
3069 assert!(
3070 required
3071 .as_array()
3072 .is_some_and(|a| a.iter().any(|v| v == "session_id"))
3073 );
3074 assert!(
3075 required
3076 .as_array()
3077 .is_some_and(|a| a.iter().any(|v| v == "url"))
3078 );
3079 assert!(
3080 required
3081 .as_array()
3082 .is_some_and(|a| a.iter().any(|v| v == "selector"))
3083 );
3084 Ok(())
3085 }
3086
3087 #[test]
3088 fn browser_extract_required_args() -> std::result::Result<(), Box<dyn std::error::Error>> {
3089 let defs = &*TOOL_DEFINITIONS;
3090 let def = defs
3091 .iter()
3092 .find(|t| t.get("name").and_then(|n| n.as_str()) == Some("browser_extract"))
3093 .ok_or("browser_extract must be in TOOL_DEFINITIONS")?;
3094 let required = def
3095 .get("inputSchema")
3096 .and_then(|s| s.get("required"))
3097 .ok_or("browser_extract inputSchema missing 'required'")?;
3098 assert!(
3099 required
3100 .as_array()
3101 .is_some_and(|a| a.iter().any(|v| v == "root_selector"))
3102 );
3103 assert!(
3104 required
3105 .as_array()
3106 .is_some_and(|a| a.iter().any(|v| v == "schema"))
3107 );
3108 Ok(())
3109 }
3110
3111 #[test]
3112 fn browser_acquire_and_extract_required_args()
3113 -> std::result::Result<(), Box<dyn std::error::Error>> {
3114 let defs = &*TOOL_DEFINITIONS;
3115 let def = defs
3116 .iter()
3117 .find(|t| t.get("name").and_then(|n| n.as_str()) == Some("browser_acquire_and_extract"))
3118 .ok_or("browser_acquire_and_extract must be in TOOL_DEFINITIONS")?;
3119
3120 let required = def
3121 .get("inputSchema")
3122 .and_then(|s| s.get("required"))
3123 .and_then(Value::as_array)
3124 .ok_or("browser_acquire_and_extract inputSchema missing 'required' array")?;
3125 assert!(required.iter().any(|v| v == "url"));
3126 assert!(required.iter().any(|v| v == "mode"));
3127
3128 let mode_values = def
3129 .get("inputSchema")
3130 .and_then(|s| s.get("properties"))
3131 .and_then(|p| p.get("mode"))
3132 .and_then(|m| m.get("enum"))
3133 .and_then(Value::as_array)
3134 .ok_or("browser_acquire_and_extract mode enum missing")?;
3135 assert!(mode_values.iter().any(|v| v == "fast"));
3136 assert!(mode_values.iter().any(|v| v == "resilient"));
3137 assert!(mode_values.iter().any(|v| v == "hostile"));
3138 assert!(mode_values.iter().any(|v| v == "investigate"));
3139 Ok(())
3140 }
3141
3142 #[test]
3143 fn acquisition_mode_parsing_accepts_all_supported_values()
3144 -> std::result::Result<(), Box<dyn std::error::Error>> {
3145 assert_eq!(
3146 McpBrowserServer::parse_acquisition_mode("fast")?,
3147 AcquisitionMode::Fast
3148 );
3149 assert_eq!(
3150 McpBrowserServer::parse_acquisition_mode("resilient")?,
3151 AcquisitionMode::Resilient
3152 );
3153 assert_eq!(
3154 McpBrowserServer::parse_acquisition_mode("hostile")?,
3155 AcquisitionMode::Hostile
3156 );
3157 assert_eq!(
3158 McpBrowserServer::parse_acquisition_mode("investigate")?,
3159 AcquisitionMode::Investigate
3160 );
3161 Ok(())
3162 }
3163
3164 #[test]
3165 fn acquisition_mode_parsing_rejects_unknown() {
3166 let err = McpBrowserServer::parse_acquisition_mode("invalid").err();
3167 assert!(err.is_some(), "invalid mode should return an error");
3168 }
3169
3170 #[test]
3171 fn acquisition_request_validation_missing_url_fails() {
3172 let err = McpBrowserServer::parse_acquisition_request(&json!({"mode": "fast"})).err();
3173 assert!(err.is_some(), "missing url should fail validation");
3174 }
3175
3176 #[test]
3177 fn acquisition_request_validation_invalid_timeout_fails() {
3178 let err = McpBrowserServer::parse_acquisition_request(&json!({
3179 "url": "https://example.com",
3180 "mode": "resilient",
3181 "total_timeout_secs": 0
3182 }))
3183 .err();
3184 assert!(err.is_some(), "zero timeout should fail validation");
3185 }
3186
3187 #[test]
3188 fn acquisition_result_output_has_stable_top_level_shape() {
3189 let result = AcquisitionResult {
3190 success: false,
3191 strategy_used: None,
3192 attempted: vec![crate::StrategyUsed::DirectHttp],
3193 final_url: Some("https://example.com".to_string()),
3194 status_code: Some(429),
3195 html_excerpt: Some("<html>blocked</html>".to_string()),
3196 extracted: None,
3197 failures: vec![crate::StageFailure {
3198 strategy: crate::StrategyUsed::DirectHttp,
3199 kind: crate::StageFailureKind::Blocked,
3200 message: "blocked status".to_string(),
3201 }],
3202 timed_out: false,
3203 freshness: None,
3204 replay_defense: None,
3205 transport_realism: None,
3206 interstitial: None,
3207 };
3208
3209 let payload = McpBrowserServer::acquisition_result_to_tool_output(&result);
3210 assert!(payload.get("success").is_some());
3211 assert!(payload.get("strategy_used").is_some());
3212 assert!(payload.get("final_url").is_some());
3213 assert!(payload.get("status_code").is_some());
3214 assert!(payload.get("html_excerpt").is_some());
3215 assert!(payload.get("diagnostics").is_some());
3216
3217 let diagnostics = payload.get("diagnostics");
3218 assert!(
3219 diagnostics
3220 .and_then(|d| d.get("attempted"))
3221 .and_then(Value::as_array)
3222 .is_some(),
3223 "diagnostics.attempted should be an array"
3224 );
3225 assert!(
3226 diagnostics
3227 .and_then(|d| d.get("failures"))
3228 .and_then(Value::as_array)
3229 .is_some(),
3230 "diagnostics.failures should be an array"
3231 );
3232 }
3233
3234 #[test]
3235 fn jsonrpc_response_ok_serializes() -> std::result::Result<(), Box<dyn std::error::Error>> {
3236 let r = JsonRpcResponse::ok(json!(1), json!({ "hello": "world" }));
3237 let s = serde_json::to_string(&r)?;
3238 assert!(s.contains("\"hello\""));
3239 assert!(s.contains("\"jsonrpc\":\"2.0\""));
3240 assert!(s.contains("\"resultType\":\"complete\""));
3242 assert!(!s.contains("\"error\""));
3243 Ok(())
3244 }
3245
3246 #[test]
3247 fn jsonrpc_response_ok_wraps_non_object_result_with_value_key()
3248 -> std::result::Result<(), Box<dyn std::error::Error>> {
3249 let r = JsonRpcResponse::ok(json!(1), json!("plain-string"));
3255 let s = serde_json::to_string(&r)?;
3256 assert!(s.contains("\"resultType\":\"complete\""));
3257 assert!(s.contains("\"value\":\"plain-string\""));
3258 Ok(())
3259 }
3260
3261 #[test]
3262 fn discover_response_advertises_protocol_version()
3263 -> std::result::Result<(), Box<dyn std::error::Error>> {
3264 let resp = McpBrowserServer::handle_discover(json!(1));
3265 let s = serde_json::to_value(resp)?;
3266 assert_eq!(
3267 s.pointer("/result/protocolVersion").and_then(Value::as_str),
3268 Some("2026-07-28")
3269 );
3270 assert_eq!(
3271 s.pointer("/result/supportedProtocolVersions")
3272 .and_then(Value::as_array)
3273 .and_then(|v| v.first())
3274 .and_then(Value::as_str),
3275 Some("2026-07-28")
3276 );
3277 assert_eq!(
3278 s.pointer("/result/serverInfo/name").and_then(Value::as_str),
3279 Some("stygian-browser")
3280 );
3281 assert_eq!(
3282 s.pointer("/result/resultType").and_then(Value::as_str),
3283 Some("complete")
3284 );
3285 assert!(
3287 s.pointer("/result/extensions")
3288 .and_then(Value::as_array)
3289 .is_some()
3290 );
3291 assert!(
3295 s.pointer("/result/capabilities/resources/subscribe")
3296 .is_none()
3297 );
3298 Ok(())
3299 }
3300
3301 #[tokio::test]
3302 async fn initialize_method_is_no_longer_recognized() {
3303 let server = McpBrowserServer::new(crate::BrowserPool::placeholder());
3306 let req = json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{}});
3307 let resp = server.dispatch(&req).await;
3308 assert_eq!(
3309 resp.pointer("/error/code"),
3310 Some(&json!(-32601)),
3311 "initialize must return Method not found"
3312 );
3313 assert!(
3314 resp.pointer("/error/message")
3315 .and_then(|m| m.as_str())
3316 .unwrap_or("")
3317 .contains("initialize"),
3318 "error message should name the rejected method"
3319 );
3320 }
3321
3322 #[tokio::test]
3323 async fn ping_method_is_no_longer_recognized() {
3324 let server = McpBrowserServer::new(crate::BrowserPool::placeholder());
3327 let req = json!({"jsonrpc":"2.0","id":1,"method":"ping","params":{}});
3328 let resp = server.dispatch(&req).await;
3329 assert_eq!(
3330 resp.pointer("/error/code"),
3331 Some(&json!(-32601)),
3332 "ping must return Method not found"
3333 );
3334 assert!(
3335 resp.pointer("/error/message")
3336 .and_then(|m| m.as_str())
3337 .unwrap_or("")
3338 .contains("ping"),
3339 "error message should name the rejected method"
3340 );
3341 }
3342
3343 #[test]
3344 fn extract_meta_reads_namespaced_keys() {
3345 let req = json!({
3349 "jsonrpc": "2.0",
3350 "id": 1,
3351 "method": "tools/list",
3352 "params": {
3353 "_meta": {
3354 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
3355 "io.modelcontextprotocol/clientInfo": {
3356 "name": "test-client",
3357 "version": "0.0.1"
3358 },
3359 "io.modelcontextprotocol/clientCapabilities": {
3360 "tools": {}
3361 },
3362 "unrelated": "ignored"
3363 }
3364 }
3365 });
3366 assert_eq!(
3367 extract_client_protocol_version(&req).as_deref(),
3368 Some("2026-07-28")
3369 );
3370 assert_eq!(
3371 extract_meta(&req, "clientInfo")
3372 .and_then(|v| v.get("name"))
3373 .and_then(Value::as_str),
3374 Some("test-client")
3375 );
3376 assert!(extract_meta(&req, "clientCapabilities").is_some());
3377 assert!(extract_meta(&req, "not-a-key").is_none());
3378
3379 let bare = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"});
3381 assert!(extract_client_protocol_version(&bare).is_none());
3382 assert!(extract_meta(&bare, "protocolVersion").is_none());
3383 }
3384
3385 #[test]
3386 fn is_supported_protocol_version_accepts_listed_and_rejects_others() {
3387 assert!(is_supported_protocol_version("2026-07-28", &["2026-07-28"]).is_ok());
3395 assert!(is_supported_protocol_version("2026-07-28", &["2025-11-25"]).is_err());
3396 assert!(is_supported_protocol_version("2025-11-25", &["2026-07-28", "2025-11-25"]).is_ok());
3397 }
3398
3399 #[test]
3400 fn jsonrpc_response_err_serializes() -> std::result::Result<(), Box<dyn std::error::Error>> {
3401 let r = JsonRpcResponse::err(json!(2), -32601, "Method not found");
3402 let s = serde_json::to_string(&r)?;
3403 assert!(s.contains("-32601"));
3404 assert!(s.contains("Method not found"));
3405 assert!(!s.contains("\"result\""));
3406 Ok(())
3407 }
3408
3409 #[test]
3410 fn browser_extract_schema_parse_empty_schema()
3411 -> std::result::Result<(), Box<dyn std::error::Error>> {
3412 let defs = &*TOOL_DEFINITIONS;
3415 let def = defs
3416 .iter()
3417 .find(|t| t.get("name").and_then(|n| n.as_str()) == Some("browser_extract"))
3418 .ok_or("browser_extract must be in TOOL_DEFINITIONS")?;
3419 let required = def
3420 .get("inputSchema")
3421 .and_then(|s| s.get("required"))
3422 .and_then(|r| r.as_array())
3423 .ok_or("browser_extract inputSchema missing 'required' array")?;
3424 assert!(
3425 required.iter().any(|v| v == "schema"),
3426 "schema must be required in browser_extract"
3427 );
3428 let schema_type = def
3430 .get("inputSchema")
3431 .and_then(|s| s.get("properties"))
3432 .and_then(|p| p.get("schema"))
3433 .and_then(|s| s.get("type"))
3434 .and_then(|t| t.as_str())
3435 .ok_or("browser_extract inputSchema.properties.schema.type missing")?;
3436 assert_eq!(
3437 schema_type, "object",
3438 "schema property must have type object"
3439 );
3440 Ok(())
3441 }
3442
3443 #[test]
3444 fn browser_query_missing_session() -> std::result::Result<(), Box<dyn std::error::Error>> {
3445 let defs = &*TOOL_DEFINITIONS;
3453 let def = defs
3454 .iter()
3455 .find(|t| t.get("name").and_then(|n| n.as_str()) == Some("browser_query"))
3456 .ok_or("browser_query must be in TOOL_DEFINITIONS")?;
3457 let required = def
3458 .get("inputSchema")
3459 .and_then(|s| s.get("required"))
3460 .and_then(|r| r.as_array())
3461 .ok_or("browser_query inputSchema missing 'required' array")?;
3462 assert!(
3464 required.iter().any(|v| v == "session_id"),
3465 "session_id must be required so missing-session is caught at validation"
3466 );
3467 Ok(())
3468 }
3469
3470 #[test]
3471 fn mcp_env_disabled_by_default() {
3472 let cases = ["false", "0", "no", "", "off"];
3474 for val in cases {
3475 assert!(!mcp_enabled_from(val), "expected disabled for {val:?}");
3476 }
3477 }
3478
3479 #[test]
3480 fn mcp_env_enabled_values() {
3481 let cases = ["true", "True", "TRUE", "1", "yes", "YES"];
3482 for val in cases {
3483 assert!(mcp_enabled_from(val), "expected enabled for {val:?}");
3484 }
3485 }
3486
3487 #[test]
3488 fn browser_warmup_in_tool_definitions() -> std::result::Result<(), Box<dyn std::error::Error>> {
3489 let defs = &*TOOL_DEFINITIONS;
3490 let def = defs
3491 .iter()
3492 .find(|t| t.get("name").and_then(|n| n.as_str()) == Some("browser_warmup"))
3493 .ok_or("browser_warmup must be in TOOL_DEFINITIONS")?;
3494 let required = def
3495 .get("inputSchema")
3496 .and_then(|s| s.get("required"))
3497 .and_then(|r| r.as_array())
3498 .ok_or("browser_warmup inputSchema missing 'required' array")?;
3499 assert!(
3500 required.iter().any(|v| v == "session_id"),
3501 "session_id must be required in browser_warmup"
3502 );
3503 assert!(
3504 required.iter().any(|v| v == "url"),
3505 "url must be required in browser_warmup"
3506 );
3507 Ok(())
3508 }
3509
3510 #[test]
3511 fn browser_refresh_in_tool_definitions() -> std::result::Result<(), Box<dyn std::error::Error>>
3512 {
3513 let defs = &*TOOL_DEFINITIONS;
3514 let def = defs
3515 .iter()
3516 .find(|t| t.get("name").and_then(|n| n.as_str()) == Some("browser_refresh"))
3517 .ok_or("browser_refresh must be in TOOL_DEFINITIONS")?;
3518 let required = def
3519 .get("inputSchema")
3520 .and_then(|s| s.get("required"))
3521 .and_then(|r| r.as_array())
3522 .ok_or("browser_refresh inputSchema missing 'required' array")?;
3523 assert!(
3524 required.iter().any(|v| v == "session_id"),
3525 "session_id must be required in browser_refresh"
3526 );
3527 Ok(())
3528 }
3529
3530 #[test]
3531 fn tool_defs_include_browser_auth_session() {
3532 let defs = &*TOOL_DEFINITIONS;
3533 assert!(
3534 defs.iter()
3535 .any(|t| t.get("name").and_then(|n| n.as_str()) == Some("browser_auth_session")),
3536 "TOOL_DEFINITIONS must contain browser_auth_session"
3537 );
3538 }
3539
3540 #[test]
3541 fn browser_auth_session_required_args() -> std::result::Result<(), Box<dyn std::error::Error>> {
3542 let defs = &*TOOL_DEFINITIONS;
3543 let def = defs
3544 .iter()
3545 .find(|t| t.get("name").and_then(|n| n.as_str()) == Some("browser_auth_session"))
3546 .ok_or("browser_auth_session must be in TOOL_DEFINITIONS")?;
3547 let required = def
3548 .get("inputSchema")
3549 .and_then(|s| s.get("required"))
3550 .and_then(Value::as_array)
3551 .ok_or("browser_auth_session inputSchema missing 'required' array")?;
3552
3553 assert!(
3554 required.iter().any(|v| v == "session_id"),
3555 "session_id must be required in browser_auth_session"
3556 );
3557 assert!(
3558 required.iter().any(|v| v == "mode"),
3559 "mode must be required in browser_auth_session"
3560 );
3561 Ok(())
3562 }
3563
3564 #[test]
3565 fn tool_defs_include_browser_session_save() {
3566 let defs = &*TOOL_DEFINITIONS;
3567 assert!(
3568 defs.iter()
3569 .any(|t| t.get("name").and_then(|n| n.as_str()) == Some("browser_session_save")),
3570 "TOOL_DEFINITIONS must contain browser_session_save"
3571 );
3572 }
3573
3574 #[test]
3575 fn tool_defs_include_browser_session_restore() {
3576 let defs = &*TOOL_DEFINITIONS;
3577 assert!(
3578 defs.iter()
3579 .any(|t| t.get("name").and_then(|n| n.as_str()) == Some("browser_session_restore")),
3580 "TOOL_DEFINITIONS must contain browser_session_restore"
3581 );
3582 }
3583
3584 #[test]
3585 fn tool_defs_include_browser_humanize() {
3586 let defs = &*TOOL_DEFINITIONS;
3587 assert!(
3588 defs.iter()
3589 .any(|t| t.get("name").and_then(|n| n.as_str()) == Some("browser_humanize")),
3590 "TOOL_DEFINITIONS must contain browser_humanize"
3591 );
3592 }
3593
3594 #[test]
3595 fn tool_defs_include_browser_apply_behavior_json() {
3596 let defs = &*TOOL_DEFINITIONS;
3597 assert!(
3598 defs.iter()
3599 .any(|t| t.get("name").and_then(|n| n.as_str())
3600 == Some("browser_apply_behavior_json")),
3601 "TOOL_DEFINITIONS must contain browser_apply_behavior_json"
3602 );
3603 }
3604
3605 #[test]
3606 fn browser_apply_behavior_json_requires_behavior()
3607 -> std::result::Result<(), Box<dyn std::error::Error>> {
3608 let defs = &*TOOL_DEFINITIONS;
3609 let def = defs
3610 .iter()
3611 .find(|t| t.get("name").and_then(|n| n.as_str()) == Some("browser_apply_behavior_json"))
3612 .ok_or("browser_apply_behavior_json must be in TOOL_DEFINITIONS")?;
3613 let required = def
3614 .get("inputSchema")
3615 .and_then(|s| s.get("required"))
3616 .and_then(Value::as_array)
3617 .ok_or("browser_apply_behavior_json inputSchema missing required array")?;
3618 assert!(
3619 required.iter().any(|v| v == "behavior"),
3620 "behavior must be required in browser_apply_behavior_json"
3621 );
3622 Ok(())
3623 }
3624
3625 #[cfg(feature = "mcp-attach")]
3626 #[test]
3627 fn tool_defs_include_browser_attach() {
3628 let defs = &*TOOL_DEFINITIONS;
3629 assert!(
3630 defs.iter()
3631 .any(|t| t.get("name").and_then(|n| n.as_str()) == Some("browser_attach")),
3632 "TOOL_DEFINITIONS must contain browser_attach when mcp-attach is enabled"
3633 );
3634 }
3635
3636 #[cfg(feature = "mcp-attach")]
3637 #[test]
3638 fn browser_attach_schema_includes_target_profile()
3639 -> std::result::Result<(), Box<dyn std::error::Error>> {
3640 let defs = &*TOOL_DEFINITIONS;
3641 let def = defs
3642 .iter()
3643 .find(|t| t.get("name").and_then(|n| n.as_str()) == Some("browser_attach"))
3644 .ok_or("browser_attach must be in TOOL_DEFINITIONS")?;
3645 let props = def
3646 .get("inputSchema")
3647 .and_then(|s| s.get("properties"))
3648 .and_then(Value::as_object)
3649 .ok_or("browser_attach inputSchema missing properties")?;
3650 let target_profile = props
3651 .get("target_profile")
3652 .ok_or("browser_attach inputSchema missing target_profile")?;
3653 let enum_values = target_profile
3654 .get("enum")
3655 .and_then(Value::as_array)
3656 .ok_or("browser_attach target_profile missing enum")?;
3657
3658 assert!(
3659 enum_values.iter().any(|v| v == "default"),
3660 "browser_attach target_profile enum must include default"
3661 );
3662 assert!(
3663 enum_values.iter().any(|v| v == "reddit"),
3664 "browser_attach target_profile enum must include reddit"
3665 );
3666 Ok(())
3667 }
3668}