Skip to main content

stygian_browser/
mcp.rs

1//! MCP (Model Context Protocol) server for browser automation.
2//!
3//! Exposes `stygian-browser` capabilities as an MCP server over stdin/stdout
4//! using the JSON-RPC 2.0 protocol.  External tools (LLM agents, IDE plugins)
5//! can acquire browsers, navigate pages, evaluate JavaScript, and capture
6//! screenshots via the standardised MCP interface.
7//!
8//! ## Enabling
9//!
10//! ```toml
11//! [dependencies]
12//! stygian-browser = { version = "*", features = ["mcp"] }
13//! ```
14//!
15//! To use `browser_attach` (`cdp_ws` mode), also enable `mcp-attach`:
16//!
17//! ```toml
18//! [dependencies]
19//! stygian-browser = { version = "*", features = ["mcp", "mcp-attach"] }
20//! ```
21//!
22//! ## Running the server
23//!
24//! ```sh
25//! STYGIAN_MCP_ENABLED=true cargo run --example mcp_server -p stygian-browser
26//! ```
27//!
28//! ## Protocol
29//!
30//! The server implements MCP 2026-07-28 over JSON-RPC 2.0 on stdin/stdout.
31//! Supported methods:
32//!
33//! | MCP Method | Description |
34//! | ----------- | ------------- |
35//! | `server/discover` | Advertise protocol versions, identity, capabilities |
36//! | `tools/list` | List available browser tools |
37//! | `tools/call` | Execute a browser tool |
38//! | `resources/list` | List active browser sessions as MCP resources |
39//! | `resources/read` | Read session state |
40//!
41//! ## Migrating from MCP 2025-11-25
42//!
43//! The `initialize` / `notifications/initialized` handshake was removed. Clients
44//! must advertise their protocol version, identity, and capabilities in the
45//! `_meta` block of every request under the `io.modelcontextprotocol/*` keys.
46//! See the `extract_client_protocol_version` and `extract_meta` helpers for
47//! the reader-side extraction (used by future PRs in the [MCP-001] migration
48//! sequence; PR 3 only adds them as helpers — enforcement lands in PR 4
49//! alongside the aggregator).
50//!
51//! [MCP-001]: https://github.com/greysquirr3l/stygian/issues/95
52//!
53//! ## Tools
54//!
55//! | Tool | Parameters | Returns |
56//! | ------ | ----------- | --------- |
57//! | `browser_acquire` | `stealth_level?`, `tls_profile?`, `webrtc_policy?`, `cdp_fix_mode?`, `proxy? (opt-in only)` | `session_id`, `requested_metadata` |
58//! | `browser_acquire_and_extract` | `url, mode, wait_for_selector?, extraction_js?, total_timeout_secs?` | `strategy_used, final_url, status_code, extracted?, html_excerpt?, diagnostics` |
59//! | `browser_navigate` | `session_id, url, timeout_secs?` | `title, url` |
60//! | `browser_eval` | `session_id, script` | `result: Value` |
61//! | `browser_screenshot` | `session_id` | `data: base64 PNG` |
62//! | `browser_content` | `session_id` | `html: String` |
63//! | `browser_attach` *(mcp-attach feature)* | `mode, endpoint?, profile_hint?, target_profile?` | attach session result |
64//! | `browser_auth_session` | `session_id, mode, file_path?, ttl_secs?, navigate_to_origin?, interaction_level?` | auth/session workflow result |
65//! | `browser_session_save` | `session_id, ttl_secs?, file_path?, include_snapshot?` | saved session state metadata |
66//! | `browser_session_restore` | `session_id, snapshot?, file_path?, use_saved?, navigate_to_origin?` | restored session state metadata |
67//! | `browser_apply_behavior_json` | `behavior, session_id?` | applied behavior plan + effective config |
68//! | `browser_humanize` | `session_id, level?, viewport_width?, viewport_height?` | humanization result |
69//! | `browser_verify_stealth` | `session_id, url, timeout_secs?` | `DiagnosticReport` JSON |
70//! | `browser_release` | `session_id` | success |
71//! | `pool_stats` | – | `active, max, available` |
72//! | `browser_query` | `session_id, url, selector, fields?, limit?, timeout_secs?` | `results` array of text or field objects |
73//! | `browser_extract` | `session_id, url, root_selector, schema, timeout_secs?` | `results` array of structured objects |
74//! | `browser_extract_with_fallback` | `session_id, url, root_selectors, schema, timeout_secs?` | first successful selector + `results` |
75//! | `browser_extract_resilient` | `session_id, url, root_selector, schema, timeout_secs?` | `results` plus skipped-count metadata |
76//! | `browser_find_similar` *(similarity feature)* | `session_id, url, reference_selector, threshold?, max_results?, timeout_secs?` | scored `matches` array |
77//! | `browser_warmup` | `session_id, url, wait?, timeout_ms?, stabilize_ms?` | warmup report |
78//! | `browser_refresh` | `session_id, wait?, timeout_ms?, reset_connection?` | refresh report |
79//!
80//! Proxy guidance: leave `proxy` unset by default. Only pass `proxy` when the
81//! user explicitly requests proxy routing or after a proxy has been acquired
82//! from the proxy pool.
83
84use 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// ─── JSON-RPC types ──────────────────────────────────────────────────────────
117
118/// A JSON-RPC 2.0 request.
119#[derive(Debug, Deserialize)]
120pub struct JsonRpcRequest {
121    /// Protocol version — always `"2.0"`.
122    pub jsonrpc: String,
123    /// Method name (e.g. `"tools/call"`).
124    pub method: String,
125    /// Method parameters.
126    #[serde(default)]
127    pub params: Value,
128    /// Request ID. `null` for notifications.
129    #[serde(default)]
130    pub id: Value,
131}
132
133/// A JSON-RPC 2.0 response.
134#[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/// A JSON-RPC 2.0 error object.
145#[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    /// Wrap a successful result in a JSON-RPC 2.0 envelope.
155    ///
156    /// MCP 2026-07-28 §8: every result carries a `resultType` field. `"complete"`
157    /// for ordinary responses; `"input_required"` for MRTR interim responses.
158    /// We only emit `"complete"` here — MRTR lands in a later migration PR.
159    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// ─── MCP 2026-07-28 _meta helpers ──────────────────────────────────────────────
199
200/// Read the `io.modelcontextprotocol/<key>` entry from a request's
201/// `params._meta` block. Returns `None` when the request omits `_meta` or
202/// the requested key is absent.
203///
204/// MCP 2026-07-28 §2: every request now carries its protocol version, client
205/// identity, and client capabilities under the `io.modelcontextprotocol/*`
206/// namespace within `params._meta`.
207#[allow(dead_code)] // Used by PR 4 (aggregator) and future per-request gates.
208fn 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/// Extract the client's advertised protocol version from a request's `_meta`.
214///
215/// Returns `None` when the field is absent. Spec mandates this be present on
216/// every 2026-07-28 request; enforcement is the aggregator's responsibility
217/// (lands in PR 4 of [MCP-001]).
218///
219/// [MCP-001]: https://github.com/greysquirr3l/stygian/issues/95
220#[allow(dead_code)] // Used by PR 4 (aggregator) and future per-request gates.
221fn 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/// Compare a client-advertised protocol version against a list of versions the
228/// server supports. Returns `Ok(())` when the version is in the supported
229/// list, `Err(unsupported)` with the offending value otherwise.
230///
231/// MCP 2026-07-28 §2: version mismatch on any request returns
232/// `UnsupportedProtocolVersionError` (code `-32022`). PR 3 exposes the
233/// helper; PR 4 wires it into the aggregator's per-request gate.
234///
235/// Uses `std::result::Result` (the module's `Result` is `Result<T,
236/// BrowserError>` and would conflict with the `String` error type we
237/// need here).
238#[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
250// ─── Session state ────────────────────────────────────────────────────────────
251
252/// An active MCP browser session.
253///
254/// The handle is wrapped in an `Arc<Mutex<Option<_>>>` so callers can clone
255/// the `Arc` and release the sessions map lock before performing long browser
256/// I/O operations.
257struct McpSession {
258    /// Pool handle for this session — `None` after [`tool_browser_release`].
259    handle: Arc<Mutex<Option<BrowserHandle>>>,
260    /// Attached browser runtime for `cdp_ws` sessions.
261    attached_browser: Arc<Mutex<Option<Browser>>>,
262    /// Background task driving the attached browser protocol handler.
263    attached_handler_task: Arc<Mutex<Option<JoinHandle<()>>>>,
264    /// Persistent page for this session. Reused across tool calls until release.
265    page: Arc<Mutex<Option<crate::page::PageHandle>>>,
266    /// Requested stealth level for this session.
267    stealth_level: StealthLevel,
268    /// Requested TLS profile name (informational — takes effect at browser launch).
269    tls_profile: Option<String>,
270    /// Requested WebRTC policy name (informational — takes effect at browser launch).
271    webrtc_policy: Option<String>,
272    /// Requested CDP fix mode for this session.
273    cdp_fix_mode: Option<String>,
274    /// Proxy URL for this session (informational — takes effect at browser launch).
275    proxy: Option<String>,
276    /// Optional target profile tuning hint used by MCP navigation helpers.
277    target_profile: String,
278    /// Last URL successfully navigated to via `browser_navigate`.
279    current_url: Option<String>,
280    /// Optional in-memory saved session snapshot for auth/session reuse.
281    saved_snapshot: Option<SessionSnapshot>,
282    /// Endpoint used by an attached browser session.
283    attach_endpoint: Option<String>,
284    /// Optional behavior plan applied via `browser_apply_behavior_json`.
285    behavior_plan: Option<crate::behavior_adapter::AppliedBehaviorPlan>,
286}
287
288// ─── MCP server ──────────────────────────────────────────────────────────────
289
290/// MCP server that exposes `BrowserPool` over stdin/stdout JSON-RPC.
291///
292/// # Example
293///
294/// ```no_run
295/// use stygian_browser::{BrowserConfig, BrowserPool};
296/// use stygian_browser::mcp::McpBrowserServer;
297/// use std::sync::Arc;
298///
299/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
300/// let pool = BrowserPool::new(BrowserConfig::default()).await?;
301/// let server = McpBrowserServer::new(pool);
302/// server.run().await?;
303/// # Ok(())
304/// # }
305/// ```
306static 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    // Advertise browser_find_similar only when the similarity feature is compiled in.
601    #[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    // Advertise browser_verify_stealth only when the stealth feature is compiled in.
619    #[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    // Advertise browser_validate_stealth only when the stealth feature is compiled in.
638    #[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    // Session warmup and refresh tools.
662    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
770/// Per-field specification parsed from a `browser_extract` schema object.
771struct ExtractFieldDef {
772    selector: String,
773    attr: Option<String>,
774    required: bool,
775}
776
777impl McpBrowserServer {
778    /// Create a new server backed by the given `pool`.
779    ///
780    /// Call [`run`](Self::run) to start the stdin/stdout event loop.
781    #[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    /// Run the JSON-RPC event loop.
790    ///
791    /// Reads newline-delimited JSON from stdin and writes responses to stdout.
792    /// Runs until stdin is closed (EOF).
793    ///
794    /// # Errors
795    ///
796    /// Returns an I/O error if stdin/stdout cannot be read from or written to.
797    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    /// Dispatch a single raw JSON-RPC request value.
849    ///
850    /// Used by the `stygian-mcp` aggregator to route tool calls through this
851    /// server without running the full stdin/stdout loop.
852    ///
853    /// # Example
854    ///
855    /// ```no_run
856    /// use stygian_browser::{BrowserConfig, BrowserPool};
857    /// use stygian_browser::mcp::McpBrowserServer;
858    /// use std::sync::Arc;
859    /// use serde_json::json;
860    ///
861    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
862    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
863    /// let server = McpBrowserServer::new(pool);
864    /// let req = json!({"jsonrpc":"2.0","id":1,"method":"server/discover"});
865    /// let resp = server.dispatch(&req).await;
866    /// assert_eq!(resp["result"]["protocolVersion"], "2026-07-28");
867    /// # Ok(())
868    /// # }
869    /// ```
870    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            // MCP 2026-07-28 §3: `server/discover` replaces the `initialize`
889            // handshake. The handshake (`initialize` + `notifications/initialized`)
890            // and the unrelated `ping` RPC are removed.
891            "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    // ── MCP lifecycle ──────────────────────────────────────────────────────────
901
902    /// Advertise the server's identity, supported protocol versions, and
903    /// capabilities. Replaces the `initialize` handshake removed in
904    /// MCP 2026-07-28.
905    ///
906    /// Note: the prior version advertised `resources.subscribe: false`. That
907    /// field is removed in 2026-07-28 since `resources/subscribe` /
908    /// `resources/unsubscribe` are replaced by the server-pushed
909    /// `subscriptions/listen` stream. Browser has no subscriptions today
910    /// (resources expose static session snapshots); the listen endpoint
911    /// lands in a follow-up PR once we have a use case for it.
912    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    // ── tools/list ────────────────────────────────────────────────────────────
932
933    fn handle_tools_list(id: Value) -> JsonRpcResponse {
934        JsonRpcResponse::ok(id, json!({ "tools": &*TOOL_DEFINITIONS }))
935    }
936
937    // ── tools/call ────────────────────────────────────────────────────────────
938
939    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        // Parse per-session config preferences.
1006        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        // Annotate with the session's requested stealth level.
1162        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        // Persist the navigated URL so that browser_content / browser_eval /
1226        // browser_screenshot can use it without the caller having to repeat it.
1227        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                            // codeql[rust/unused-variable] - `error` is used via the captured format arg below.
1411                            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        // Parse optional fields map: { "fieldName": { "attr"?: "attrName" } }
1872        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        // Parse schema: { "fieldName": { "selector": "...", "attr"?: "...", "required"?: bool } }
1958        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        // Resolve the reference node — first match only.
2092        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        // Remove session from the map so further calls immediately fail.
2252        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        // Take and release the handle without holding the map lock
2267        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        // Parse target list, defaulting to Tier 1 (CreepJS, BrowserScan)
2314        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        // Run validators with per-target timeout so MCP responses remain bounded.
2339        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        // Serialize results
2356        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    // ── resources/list ────────────────────────────────────────────────────────
2370
2371    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    // ── resources/read ────────────────────────────────────────────────────────
2390
2391    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        // Parse browser://session/<session_id>
2398        let session_id = uri
2399            .strip_prefix("browser://session/")
2400            .unwrap_or("")
2401            .to_string();
2402
2403        // Read session config while holding the map lock, then release.
2404        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    // ── Helper ────────────────────────────────────────────────────────────────
2446
2447    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            // codeql[rust/unused-variable] - `session_id` is used via the captured format args below.
2518            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    // ── browser_extract_with_fallback ─────────────────────────────────────────
2639
2640    /// Extract using the first `root_selectors` entry that yields results.
2641    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    // ── browser_extract_resilient ─────────────────────────────────────────────
2716
2717    /// Extract from every root node matching `root_selector`, silently
2718    /// dropping nodes where *all* required schema fields are absent.
2719    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        // Resilient mode: `extract_record` returns None when a required field is
2757        // missing.  We count those as "skipped" rather than bubbling an error.
2758        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
2963/// Returns `true` if `value` is a truthy string (`"true"`, `"1"`, or `"yes"`,
2964/// case-insensitive).
2965fn mcp_enabled_from(value: &str) -> bool {
2966    matches!(value.to_lowercase().as_str(), "true" | "1" | "yes")
2967}
2968
2969/// Returns `true` if the MCP server is enabled via the `STYGIAN_MCP_ENABLED`
2970/// environment variable.
2971///
2972/// Set `STYGIAN_MCP_ENABLED=true` to enable the server.
2973#[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        // The inputSchema for browser_query must list session_id, url, selector as required.
3060        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        // MCP 2026-07-28 §8: every result envelope carries `resultType: "complete"`.
3241        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        // Defensive branch: if a caller passes a non-object payload, the
3250        // envelope still carries `resultType: "complete"` and the value
3251        // is parked under `value`. The MCP spec only defines object
3252        // results, but a bug in a caller should not produce an invalid
3253        // envelope.
3254        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        // MCP 2026-07-28 §8: extensions array present even when empty.
3286        assert!(
3287            s.pointer("/result/extensions")
3288                .and_then(Value::as_array)
3289                .is_some()
3290        );
3291        // The deprecated `resources.subscribe: false` capability is gone —
3292        // `subscriptions/listen` replaces `resources/subscribe` (lands in
3293        // a follow-up PR once browser has a use case for the listen stream).
3294        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        // MCP 2026-07-28 removed the `initialize` handshake. The browser
3304        // server's dispatch routes unknown methods to -32601.
3305        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        // `ping` is removed in MCP 2026-07-28; same as
3325        // `initialize_method_is_no_longer_recognized`.
3326        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        // The `_meta` reader must look under `params._meta.io.modelcontextprotocol/*`.
3346        // `protocolVersion`, `clientInfo`, `clientCapabilities` are the three
3347        // carriers defined by MCP 2026-07-28 §2.
3348        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        // `_meta` absent → all helpers return None.
3380        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        // MCP 2026-07-28 §2: clients advertise their protocol version on every
3388        // request. Servers reject unknown versions with
3389        // `UnsupportedProtocolVersionError` (code `-32022`). The aggregator
3390        // (PR 4 of MCP-001) wires this check into the per-request gate; the
3391        // browser server itself stays permissive at this layer because it
3392        // is also called directly via `tools/list` / `tools/call` and the
3393        // dispatcher doesn't enforce `_meta` yet.
3394        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        // An empty schema object parses without error and yields an empty field list.
3413        // We validate this by ensuring browser_extract's inputSchema requires "schema".
3414        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        // Also confirm the schema property type is "object"
3429        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        // Verify that `browser_query` with a missing `session_id` arg
3446        // returns the right `isError` shape via the dispatch JSON structure.
3447        // We test the tool-call dispatch by inspecting that an unknown session
3448        // is handled as an `isError` result rather than a JSON-RPC error code.
3449        // Because constructing a real BrowserPool requires Chrome, we instead
3450        // verify the shape through the TOOL_DEFINITIONS contract: session_id
3451        // is required so any call without it would fail at arg-validation.
3452        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        // session_id required → missing session will always be caught
3463        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        // If STYGIAN_MCP_ENABLED is not "true"/"1"/"yes", function returns false
3473        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}