#![cfg(feature = "mcp")]
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use serde_json::{Value, json};
use stygian_browser::BrowserConfig;
use stygian_browser::BrowserPool;
use stygian_browser::config::PoolConfig;
use stygian_browser::mcp::McpBrowserServer;
type DynError = Box<dyn std::error::Error>;
fn unique_user_data_dir() -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
std::env::temp_dir().join(format!("stygian-mcp-itest-{pid}-{n}"))
}
fn test_config() -> BrowserConfig {
let mut cfg = BrowserConfig::builder()
.headless(true)
.pool(PoolConfig {
min_size: 0,
max_size: 1,
acquire_timeout: Duration::from_secs(10),
..PoolConfig::default()
})
.build();
cfg.launch_timeout = Duration::from_secs(30);
cfg.cdp_timeout = Duration::from_secs(15);
cfg.user_data_dir = Some(unique_user_data_dir());
if let Ok(p) = std::env::var("STYGIAN_CHROME_PATH") {
cfg.chrome_path = Some(PathBuf::from(p));
}
cfg
}
fn parse_tools_call_text(resp: &Value) -> Result<Value, DynError> {
let is_error = resp
.get("result")
.and_then(|r| r.get("isError"))
.and_then(Value::as_bool)
.unwrap_or(true);
if is_error {
return Err(
std::io::Error::other(format!("MCP tool returned isError=true: {resp}")).into(),
);
}
let text = resp
.get("result")
.and_then(|r| r.get("content"))
.and_then(Value::as_array)
.and_then(|arr| arr.first())
.and_then(|entry| entry.get("text"))
.and_then(Value::as_str)
.ok_or_else(|| std::io::Error::other(format!("missing tools/call text payload: {resp}")))?;
Ok(serde_json::from_str(text)?)
}
async fn call_tool(
server: &McpBrowserServer,
id: u64,
name: &str,
arguments: Value,
) -> Result<Value, DynError> {
let resp = server
.dispatch(&json!({
"jsonrpc": "2.0",
"id": id,
"method": "tools/call",
"params": {
"name": name,
"arguments": arguments
}
}))
.await;
parse_tools_call_text(&resp)
}
fn session_id_from_payload(payload: &Value) -> Result<String, DynError> {
payload
.get("session_id")
.and_then(Value::as_str)
.map(ToString::to_string)
.ok_or_else(|| std::io::Error::other("tool response returned no session_id").into())
}
async fn acquire_session(
server: &McpBrowserServer,
id: u64,
target_profile: &str,
) -> Result<String, DynError> {
let payload = call_tool(
server,
id,
"browser_acquire",
json!({ "target_profile": target_profile }),
)
.await?;
session_id_from_payload(&payload)
}
async fn release_session(
server: &McpBrowserServer,
id: u64,
session_id: &str,
) -> Result<Value, DynError> {
call_tool(
server,
id,
"browser_release",
json!({ "session_id": session_id }),
)
.await
}
async fn assert_session_save_ttl(
server: &McpBrowserServer,
id: u64,
session_id: &str,
ttl_secs: u64,
) -> Result<(), DynError> {
let save_payload = call_tool(
server,
id,
"browser_session_save",
json!({
"session_id": session_id,
"ttl_secs": ttl_secs
}),
)
.await?;
assert_eq!(
save_payload.get("ttl_secs").and_then(Value::as_u64),
Some(ttl_secs),
"saved snapshot should persist requested ttl"
);
Ok(())
}
async fn assert_session_restore_saved(
server: &McpBrowserServer,
id: u64,
session_id: &str,
) -> Result<(), DynError> {
let restore_payload = call_tool(
server,
id,
"browser_session_restore",
json!({
"session_id": session_id,
"use_saved": true,
"navigate_to_origin": false
}),
)
.await?;
assert_eq!(
restore_payload.get("source").and_then(Value::as_str),
Some("saved"),
"restore should use in-memory saved snapshot by default"
);
Ok(())
}
async fn assert_humanize_applied(
server: &McpBrowserServer,
id: u64,
session_id: &str,
) -> Result<(), DynError> {
let humanize_payload = call_tool(
server,
id,
"browser_humanize",
json!({
"session_id": session_id,
"level": "none"
}),
)
.await?;
assert_eq!(
humanize_payload.get("applied").and_then(Value::as_bool),
Some(true),
"humanize should report applied=true"
);
Ok(())
}
async fn assert_auth_session_mode(
server: &McpBrowserServer,
id: u64,
session_id: &str,
mode: &str,
ttl_secs: Option<u64>,
) -> Result<(), DynError> {
let mut args = json!({
"session_id": session_id,
"mode": mode,
"navigate_to_origin": false,
"interaction_level": "none"
});
if mode == "capture"
&& let Some(ttl) = ttl_secs
&& let Some(obj) = args.as_object_mut()
{
obj.insert("ttl_secs".to_string(), Value::from(ttl));
}
let payload = call_tool(server, id, "browser_auth_session", args).await?;
assert_eq!(
payload.get("mode").and_then(Value::as_str),
Some(mode),
"auth session wrapper should report {mode} mode"
);
Ok(())
}
#[cfg(feature = "mcp-attach")]
async fn assert_attach_contract(server: &McpBrowserServer, id: u64) -> Result<(), DynError> {
let attach_contract_payload = call_tool(
server,
id,
"browser_attach",
json!({
"mode": "extension_bridge",
"profile_hint": "reddit-main"
}),
)
.await?;
assert_eq!(
attach_contract_payload
.get("supported")
.and_then(Value::as_bool),
Some(false),
"extension bridge mode should clearly report unsupported until backend is implemented"
);
Ok(())
}
#[cfg(feature = "mcp-attach")]
async fn attach_cdp_ws_session(
server: &McpBrowserServer,
id: u64,
endpoint: &str,
) -> Result<String, DynError> {
let payload = call_tool(
server,
id,
"browser_attach",
json!({
"mode": "cdp_ws",
"endpoint": endpoint,
"target_profile": "default"
}),
)
.await?;
assert_eq!(
payload.get("supported").and_then(Value::as_bool),
Some(true),
"cdp_ws attach should return supported=true when connect succeeds"
);
session_id_from_payload(&payload)
}
#[tokio::test]
#[ignore = "requires Chrome and external network"]
async fn mcp_acquire_navigate_release_round_trip() -> Result<(), Box<dyn std::error::Error>> {
let pool = BrowserPool::new(test_config()).await?;
let server = McpBrowserServer::new(pool);
let acquire_resp = server
.dispatch(&json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "browser_acquire",
"arguments": {
"stealth_level": "advanced",
"target_profile": "default"
}
}
}))
.await;
let acquire_payload = parse_tools_call_text(&acquire_resp)?;
let session_id = acquire_payload
.get("session_id")
.and_then(Value::as_str)
.ok_or_else(|| std::io::Error::other("tool response returned no session_id"))?
.to_string();
let navigate_resp = server
.dispatch(&json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "browser_navigate",
"arguments": {
"session_id": session_id,
"url": "https://example.com",
"timeout_secs": 30
}
}
}))
.await;
let navigate_payload = parse_tools_call_text(&navigate_resp)?;
let title = navigate_payload
.get("title")
.and_then(Value::as_str)
.unwrap_or_default()
.to_lowercase();
assert!(
title.contains("example"),
"expected navigation title to contain 'example', got payload: {navigate_payload}"
);
let release_resp = server
.dispatch(&json!({
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "browser_release",
"arguments": {
"session_id": session_id
}
}
}))
.await;
let release_payload = parse_tools_call_text(&release_resp)?;
assert_eq!(
release_payload.get("released").and_then(Value::as_bool),
Some(true),
"browser_release should return released=true; got payload: {release_payload}"
);
Ok(())
}
#[tokio::test]
#[ignore = "requires Chrome and external network"]
async fn mcp_browser_acquire_and_extract_returns_structured_payload() -> Result<(), DynError> {
let pool = BrowserPool::new(test_config()).await?;
let server = McpBrowserServer::new(pool);
let payload = call_tool(
&server,
200,
"browser_acquire_and_extract",
json!({
"url": "https://example.com",
"mode": "resilient",
"total_timeout_secs": 20
}),
)
.await?;
assert!(payload.get("success").is_some());
assert!(payload.get("strategy_used").is_some());
assert!(payload.get("final_url").is_some());
assert!(payload.get("status_code").is_some());
assert!(payload.get("diagnostics").is_some());
let diagnostics = payload
.get("diagnostics")
.ok_or_else(|| std::io::Error::other("missing diagnostics bundle"))?;
assert!(
diagnostics
.get("attempted")
.and_then(Value::as_array)
.is_some(),
"diagnostics.attempted should be an array"
);
assert!(
diagnostics
.get("failures")
.and_then(Value::as_array)
.is_some(),
"diagnostics.failures should be an array"
);
Ok(())
}
#[tokio::test]
#[ignore = "requires Chrome and external network"]
async fn mcp_session_save_restore_and_humanize_round_trip() -> Result<(), Box<dyn std::error::Error>>
{
let pool = BrowserPool::new(test_config()).await?;
let server = McpBrowserServer::new(pool);
let session_id = acquire_session(&server, 11, "reddit").await?;
let _ = call_tool(
&server,
12,
"browser_navigate",
json!({
"session_id": session_id,
"url": "https://example.com",
"timeout_secs": 30
}),
)
.await?;
assert_session_save_ttl(&server, 13, &session_id, 3600).await?;
assert_session_restore_saved(&server, 14, &session_id).await?;
assert_humanize_applied(&server, 15, &session_id).await?;
#[cfg(feature = "mcp-attach")]
{
assert_attach_contract(&server, 16).await?;
}
assert_auth_session_mode(&server, 17, &session_id, "capture", Some(1800)).await?;
assert_auth_session_mode(&server, 18, &session_id, "resume", None).await?;
let release_payload = release_session(&server, 19, &session_id).await?;
assert_eq!(
release_payload.get("released").and_then(Value::as_bool),
Some(true),
"browser_release should return released=true"
);
Ok(())
}
#[cfg(feature = "mcp-attach")]
#[tokio::test]
#[ignore = "requires STYGIAN_ATTACH_WS_ENDPOINT and reachable DevTools websocket"]
async fn mcp_attach_cdp_ws_navigate_release_round_trip() -> Result<(), Box<dyn std::error::Error>> {
let endpoint = match std::env::var("STYGIAN_ATTACH_WS_ENDPOINT") {
Ok(v) if !v.trim().is_empty() => v,
_ => {
eprintln!(
"Skipping mcp_attach_cdp_ws_navigate_release_round_trip: STYGIAN_ATTACH_WS_ENDPOINT is not set"
);
return Ok(());
}
};
let pool = BrowserPool::new(test_config()).await?;
let server = McpBrowserServer::new(pool);
let session_id = attach_cdp_ws_session(&server, 31, &endpoint).await?;
let navigate_payload = call_tool(
&server,
32,
"browser_navigate",
json!({
"session_id": session_id,
"url": "https://example.com",
"timeout_secs": 30
}),
)
.await?;
let title = navigate_payload
.get("title")
.and_then(Value::as_str)
.unwrap_or_default()
.to_lowercase();
assert!(
title.contains("example"),
"expected attached session navigation title to contain 'example', got payload: {navigate_payload}"
);
let release_payload = release_session(&server, 33, &session_id).await?;
assert_eq!(
release_payload.get("released").and_then(Value::as_bool),
Some(true),
"browser_release should return released=true for attached session"
);
Ok(())
}