use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::time::Duration;
use serde_json::{json, Value};
use super::browser::BrowserRun;
use super::RawFetch;
pub const DEFAULT_MCP_ENDPOINT: &str = "https://edge.actionbook.dev/mcp";
pub fn run(
slug: &str,
tab_n: u32,
url: &str,
_readable: bool,
timeout_ms: u64,
frame_id: Option<u32>,
run_code_args: Option<&Value>,
) -> Result<BrowserRun, String> {
let api_key = require_api_key()?;
let endpoint = endpoint();
let handle = handle_for(slug, tab_n);
let mut client = McpClient::new(endpoint, api_key, slug.to_string(), timeout_ms);
client.ensure_initialized()?;
let goto_cmd = build_new_tab_cmd(url, &handle);
let runcode_cmd =
build_runcode_cmd_for_url(url, &handle, timeout_ms, frame_id, run_code_args);
let close_cmd = build_close_cmd(&handle);
client.call_tool(&goto_cmd)?;
let runcode_result = match client.call_tool(&runcode_cmd) {
Ok(text) => text,
Err(e) if is_recoverable_handle_loss(&e) => {
client.call_tool(&goto_cmd)?;
client.call_tool(&runcode_cmd)?
}
Err(e) => return Err(e),
};
let _ = client.call_tool(&close_cmd);
let extracted = extract_run_code_payload(&runcode_result)?;
Ok(BrowserRun {
raw: RawFetch {
raw_stdout: runcode_result.into_bytes(),
raw_stderr: Vec::new(),
exit_code: 0,
duration_ms: 0,
},
observed_url: extracted.url,
body: extracted.text.into_bytes(),
})
}
pub fn call_actionbook_tool(cmd: &str, slug: &str, timeout_ms: u64) -> Result<String, String> {
let api_key = require_api_key()?;
let endpoint = endpoint();
let mut client = McpClient::new(endpoint, api_key, slug.to_string(), timeout_ms);
client.ensure_initialized()?;
client.call_tool(cmd)
}
pub fn is_api_key_set() -> bool {
matches!(std::env::var("ACTIONBOOK_API_KEY"), Ok(v) if !v.trim().is_empty())
}
fn require_api_key() -> Result<String, String> {
match std::env::var("ACTIONBOOK_API_KEY") {
Ok(v) if !v.trim().is_empty() => Ok(v),
_ => Err(
"ACTIONBOOK_API_KEY unset: set ACTIONBOOK_API_KEY (an ak_* token from \
actionbook.dev), or run 'actionbook auth login'. \
OAuth interactive flow is out of scope for the V2 backend.".to_string(),
),
}
}
pub fn endpoint() -> String {
std::env::var("ACTIONBOOK_MCP_ENDPOINT")
.unwrap_or_else(|_| DEFAULT_MCP_ENDPOINT.to_string())
}
pub fn handle_for(slug: &str, tab_n: u32) -> String {
match std::env::var("ACTIONBOOK_BROWSER_SESSION") {
Ok(s) if !s.trim().is_empty() => format!("{}-{}-{}", s.trim(), slug, tab_n),
_ => format!("research-{}-{}", slug, tab_n),
}
}
pub fn session_id_path(slug: &str) -> PathBuf {
let root = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
root.join(".actionbook")
.join("ascent-research")
.join("sessions")
.join(slug)
.join(".mcp-session")
}
pub fn build_runcode_cmd(
handle: &str,
caller_timeout_ms: u64,
frame_id: Option<u32>,
run_code_args: Option<&Value>,
) -> String {
let inner_timeout_ms = caller_timeout_ms
.saturating_sub(5_000)
.min(115_000)
.max(5_000);
let mut cmd = format!("browser run-code --tab {handle} --timeout {inner_timeout_ms}");
if let Some(fid) = frame_id {
cmd.push_str(&format!(" --frame-id {fid}"));
}
if let Some(args) = run_code_args {
let literal = serde_json::to_string(args)
.unwrap_or_else(|_| "[]".to_string());
cmd.push_str(&format!(" --args '{literal}'"));
}
cmd.push_str(&format!(" '{}'", runcode_inline_js().replace('\'', "\\'")));
cmd
}
pub fn build_new_tab_cmd(url: &str, handle: &str) -> String {
format!("browser new-tab {url} --tab {handle}")
}
pub fn build_close_cmd(handle: &str) -> String {
format!("browser close --tab {handle}")
}
pub fn runcode_inline_js() -> &'static str {
"async (page) => { \
try { await page.waitForLoadState(\"domcontentloaded\", { timeout: 8000 }); } catch (_e) {} \
try { await page.waitForLoadState(\"networkidle\", { timeout: 3000 }); } catch (_e) {} \
for (let i = 0; i < 20; i++) { \
if (document.body && document.body.innerText && document.body.innerText.length > 100) break; \
await new Promise(r => setTimeout(r, 250)); \
} \
return { url: page.url(), title: await page.title(), text: document.body.innerText }; \
}"
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuncodeFlavor {
Default,
XTweet,
}
pub fn flavor_for_url(url: &str) -> RuncodeFlavor {
let Some(parsed) = crate::route::rules::ParsedUrl::parse(url) else {
return RuncodeFlavor::Default;
};
match parsed.host.as_str() {
"x.com" | "www.x.com" | "mobile.x.com"
| "twitter.com" | "www.twitter.com" | "mobile.twitter.com" => RuncodeFlavor::XTweet,
_ => RuncodeFlavor::Default,
}
}
pub fn runcode_inline_js_x_tweet() -> &'static str {
"async (page) => { \
try { await page.waitForLoadState(\"domcontentloaded\", { timeout: 8000 }); } catch (_e) {} \
try { await page.waitForSelector('article[data-testid=\"tweet\"], [data-testid=\"cellInnerDiv\"], [data-testid=\"UserName\"]', { timeout: 15000 }); } catch (_e) {} \
const MAX_SCROLLS = 8; \
const MAX_ARTICLES = 25; \
const seen = new Map(); \
const snapshot = () => { \
document.querySelectorAll('article[data-testid=\"tweet\"]').forEach(a => { \
const link = a.querySelector('a[href*=\"/status/\"]'); \
const m = link ? link.getAttribute('href').match(/\\/status\\/(\\d+)/) : null; \
const id = m ? m[1] : ('idx-' + seen.size); \
if (seen.has(id)) return; \
const txt = a.innerText; \
const imgs = Array.from(a.querySelectorAll('img')).map(i => i.src).filter(s => s.includes('pbs.twimg.com/media') || s.includes('pbs.twimg.com/tweet_video_thumb') || s.includes('pbs.twimg.com/card_img')); \
const vids = Array.from(a.querySelectorAll('video')).map(v => v.poster || v.src).filter(Boolean); \
const media = imgs.concat(vids).map(u => '').join('\\n'); \
seen.set(id, media ? (txt + '\\n\\n' + media) : txt); \
}); \
}; \
snapshot(); \
for (let s = 0; s < MAX_SCROLLS; s++) { \
if (seen.size >= MAX_ARTICLES) break; \
const before = seen.size; \
window.scrollBy(0, window.innerHeight * 0.8); \
await new Promise(r => setTimeout(r, 1200)); \
snapshot(); \
if (seen.size === before) break; \
} \
await new Promise(r => setTimeout(r, 500)); \
snapshot(); \
const ordered = Array.from(seen.values()).slice(0, MAX_ARTICLES); \
const text = ordered.length > 0 ? ordered.join('\\n\\n---\\n\\n') : document.body.innerText; \
return { url: page.url(), title: await page.title(), text }; \
}"
}
pub fn runcode_inline_js_for(flavor: RuncodeFlavor) -> &'static str {
match flavor {
RuncodeFlavor::Default => runcode_inline_js(),
RuncodeFlavor::XTweet => runcode_inline_js_x_tweet(),
}
}
pub fn build_runcode_cmd_for_url(
url: &str,
handle: &str,
caller_timeout_ms: u64,
frame_id: Option<u32>,
run_code_args: Option<&Value>,
) -> String {
let flavor = flavor_for_url(url);
let inner_timeout_ms = caller_timeout_ms
.saturating_sub(5_000)
.min(115_000)
.max(5_000);
let mut cmd = format!("browser run-code --tab {handle} --timeout {inner_timeout_ms}");
if let Some(fid) = frame_id {
cmd.push_str(&format!(" --frame-id {fid}"));
}
if let Some(args) = run_code_args {
let literal = serde_json::to_string(args).unwrap_or_else(|_| "[]".to_string());
cmd.push_str(&format!(" --args '{literal}'"));
}
cmd.push_str(&format!(
" '{}'",
runcode_inline_js_for(flavor).replace('\'', "\\'")
));
cmd
}
struct McpClient {
endpoint: String,
api_key: String,
slug: String,
timeout_ms: u64,
mcp_session_id: Option<String>,
request_id: u64,
}
impl McpClient {
fn new(endpoint: String, api_key: String, slug: String, timeout_ms: u64) -> Self {
Self {
endpoint,
api_key,
slug,
timeout_ms,
mcp_session_id: None,
request_id: 0,
}
}
fn ensure_initialized(&mut self) -> Result<(), String> {
let path = session_id_path(&self.slug);
if let Ok(persisted) = fs::read_to_string(&path) {
let id = persisted.trim().to_string();
if !id.is_empty() {
self.mcp_session_id = Some(id);
return Ok(());
}
}
let init_body = json!({
"jsonrpc": "2.0",
"id": self.next_id(),
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": { "name": "ascent-research", "version": env!("CARGO_PKG_VERSION") }
}
});
let (id_header, _body) = self.raw_post(&init_body)?;
let id = id_header.ok_or_else(|| {
"MCP server did not return Mcp-Session-Id header on initialize".to_string()
})?;
self.mcp_session_id = Some(id.clone());
persist_session_id(&path, &id)?;
let notif = json!({
"jsonrpc": "2.0",
"method": "notifications/initialized",
"params": {}
});
let _ = self.raw_post(¬if);
Ok(())
}
fn next_id(&mut self) -> u64 {
self.request_id += 1;
self.request_id
}
fn call_tool(&mut self, cmd: &str) -> Result<String, String> {
let body = json!({
"jsonrpc": "2.0",
"id": self.next_id(),
"method": "tools/call",
"params": {
"name": "actionbook",
"arguments": { "cmd": cmd }
}
});
let (maybe_new_id, response_body) = self.raw_post(&body)?;
if let Some(new_id) = maybe_new_id {
if Some(new_id.as_str()) != self.mcp_session_id.as_deref() {
let _ = persist_session_id(&session_id_path(&self.slug), &new_id);
self.mcp_session_id = Some(new_id);
}
}
let parsed: Value = serde_json::from_str(&response_body)
.map_err(|e| format!("INTERNAL_ERROR: malformed MCP response body: {e}"))?;
if let Some(err) = parsed.get("error") {
let code = err.get("code").and_then(Value::as_str)
.or_else(|| err.get("code").and_then(|c| c.as_i64()).map(|_| "JSON_RPC_ERROR"))
.unwrap_or("UNKNOWN_ERROR")
.to_string();
let message = err.get("message").and_then(Value::as_str)
.unwrap_or("(no message)").to_string();
return Err(format!("{}: {}", error_prefix_for(&code, &message), message));
}
let content = parsed
.get("result")
.and_then(|r| r.get("content"))
.and_then(Value::as_array)
.ok_or_else(|| "INTERNAL_ERROR: tool result missing content array".to_string())?;
let text = content
.iter()
.find_map(|item| item.get("text").and_then(Value::as_str))
.ok_or_else(|| "INTERNAL_ERROR: tool result content has no text item".to_string())?
.to_string();
if let Some(code_msg) = parse_inline_error(&text) {
return Err(format!(
"{}: {}",
error_prefix_for(&code_msg.code, &code_msg.message),
code_msg.message
));
}
Ok(text)
}
fn raw_post(&self, body: &Value) -> Result<(Option<String>, String), String> {
let timeout = Duration::from_millis(self.timeout_ms.max(1_000));
let agent = ureq::AgentBuilder::new()
.timeout(timeout)
.build();
let mut req = agent
.post(&self.endpoint)
.set("Content-Type", "application/json")
.set("Accept", "application/json, text/event-stream")
.set("Authorization", &format!("Bearer {}", self.api_key));
if let Some(id) = &self.mcp_session_id {
req = req.set("Mcp-Session-Id", id);
}
let resp_result = req.send_json(body.clone());
let resp = match resp_result {
Ok(r) => r,
Err(ureq::Error::Status(code, r)) => {
let body = r.into_string().unwrap_or_default();
let prefix = if code == 401 || code == 403 {
"EXTENSION_OFFLINE"
} else {
"INTERNAL_ERROR"
};
let snippet: String = body.chars().take(300).collect();
return Err(format!("{prefix}: HTTP {code}: {snippet}"));
}
Err(ureq::Error::Transport(t)) => {
return Err(format!("INTERNAL_ERROR: MCP transport: {t}"));
}
};
let id = resp.header("Mcp-Session-Id").map(str::to_string);
let body = resp.into_string()
.map_err(|e| format!("INTERNAL_ERROR: MCP response read: {e}"))?;
Ok((id, body))
}
}
#[derive(Debug)]
struct InlineErr {
code: String,
message: String,
}
fn parse_inline_error(text: &str) -> Option<InlineErr> {
for line in text.lines() {
let line = line.trim_start();
let Some(rest) = line.strip_prefix("error ") else { continue };
let Some((code, message)) = rest.split_once(':') else { continue };
if code.chars().all(|c| c.is_ascii_uppercase() || c == '_') && !code.is_empty() {
return Some(InlineErr {
code: code.to_string(),
message: message.trim().to_string(),
});
}
}
None
}
fn error_prefix_for(code: &str, message: &str) -> &'static str {
match code {
"EXTENSION_OFFLINE" => "EXTENSION_OFFLINE",
"SESSION_LOST" => "SESSION_LOST",
"TAB_NOT_FOUND" => "TAB_NOT_FOUND",
"CANCELLED" => "CANCELLED",
"NAVIGATION_FAILED" => "NAVIGATION_FAILED",
"PAYLOAD_TOO_LARGE" => "PAYLOAD_TOO_LARGE",
"TIMEOUT" => "TIMEOUT",
"INVALID_ARGUMENT" => "INVALID_ARGUMENT",
"ELEMENT_NOT_FOUND" | "MULTIPLE_MATCHES" | "EVAL_FAILED" => "RUN_CODE_FAILED",
"INTERNAL_ERROR" if message.contains("chrome-extension://")
|| message.contains("Detached while handling command") =>
{
"DEBUGGER_ATTACH_CONFLICT"
}
_ => "INTERNAL_ERROR",
}
}
fn is_recoverable_handle_loss(err: &str) -> bool {
err.starts_with("SESSION_LOST:") || err.starts_with("TAB_NOT_FOUND:")
}
struct RunCodePayload {
url: String,
text: String,
}
fn extract_run_code_payload(tool_result_text: &str) -> Result<RunCodePayload, String> {
let start = tool_result_text
.find('{')
.ok_or_else(|| "INTERNAL_ERROR: run-code output has no JSON body".to_string())?;
let json_part = &tool_result_text[start..];
let parsed: Value = serde_json::from_str(json_part)
.map_err(|e| format!("INTERNAL_ERROR: run-code JSON parse: {e}"))?;
let result = parsed.get("result").ok_or_else(|| {
"INTERNAL_ERROR: run-code envelope missing `result` field".to_string()
})?;
let url = result
.get("url")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let text = result
.get("text")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
Ok(RunCodePayload { url, text })
}
fn persist_session_id(path: &PathBuf, id: &str) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("INTERNAL_ERROR: mkdir .mcp-session parent: {e}"))?;
}
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(path)
.map_err(|e| format!("INTERNAL_ERROR: open .mcp-session: {e}"))?;
file.write_all(id.as_bytes())
.map_err(|e| format!("INTERNAL_ERROR: write .mcp-session: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn with_env<F: FnOnce()>(key: &str, val: Option<&str>, f: F) {
let prev = std::env::var(key).ok();
match val {
Some(v) => unsafe { std::env::set_var(key, v) },
None => unsafe { std::env::remove_var(key) },
}
f();
match prev {
Some(p) => unsafe { std::env::set_var(key, p) },
None => unsafe { std::env::remove_var(key) },
}
}
#[test]
fn endpoint_default_is_production() {
assert_eq!(DEFAULT_MCP_ENDPOINT, "https://edge.actionbook.dev/mcp");
}
#[test]
fn v2_tab_handle_naming_default() {
with_env("ACTIONBOOK_BROWSER_SESSION", None, || {
assert_eq!(handle_for("demo", 2), "research-demo-2");
});
}
#[test]
fn v2_tab_handle_prefix_via_env() {
with_env("ACTIONBOOK_BROWSER_SESSION", Some("foo"), || {
assert_eq!(handle_for("demo", 1), "foo-demo-1");
});
}
#[test]
fn v2_tab_handle_empty_env_falls_back_to_default() {
with_env("ACTIONBOOK_BROWSER_SESSION", Some(" "), || {
assert_eq!(handle_for("demo", 3), "research-demo-3");
});
}
#[test]
fn v2_api_key_unset_fail_fast() {
with_env("ACTIONBOOK_API_KEY", None, || {
let err = require_api_key().expect_err("missing key must be fatal");
assert!(err.contains("ACTIONBOOK_API_KEY"));
assert!(err.contains("actionbook auth login"));
});
}
#[test]
fn v2_api_key_blank_treated_as_unset() {
with_env("ACTIONBOOK_API_KEY", Some(" "), || {
assert!(require_api_key().is_err());
});
}
#[test]
fn v2_runcode_cmd_includes_inner_timeout() {
let cmd = build_runcode_cmd("h", 90_000, None, None);
assert!(cmd.starts_with("browser run-code --tab h --timeout "));
assert!(cmd.contains("--timeout 85000"), "expected 85000, got: {cmd}");
}
#[test]
fn v2_runcode_cmd_clamps_at_115s_max() {
let cmd = build_runcode_cmd("h", 1_000_000, None, None); assert!(cmd.contains("--timeout 115000"), "expected 115000 cap, got: {cmd}");
}
#[test]
fn v2_runcode_cmd_floor_at_5s_min() {
let cmd = build_runcode_cmd("h", 1_000, None, None); assert!(cmd.contains("--timeout 5000"), "expected 5000 floor, got: {cmd}");
}
#[test]
fn v2_runcode_cmd_default_caller_90s_yields_inner_85s() {
let cmd = build_runcode_cmd("research-foo-1", 90_000, None, None);
assert!(cmd.contains("--timeout 85000"));
assert!(85_000 > 60_000);
}
#[test]
fn v2_runcode_is_function_expression_with_three_stage_wait() {
let js = runcode_inline_js();
assert!(js.starts_with("async (page) =>"), "must be function expression, not IIFE: {js}");
assert!(js.contains("domcontentloaded"), "stage 1 missing: {js}");
assert!(js.contains("networkidle"), "stage 2 missing: {js}");
assert!(
js.contains("document.body.innerText.length > 100"),
"stage 3 (body-content poll) missing: {js}"
);
let try_count = js.matches("try {").count();
let catch_count = js.matches("catch (_e)").count();
assert!(try_count >= 2, "expect ≥2 try blocks, got {try_count}: {js}");
assert!(catch_count >= 2, "expect ≥2 catch blocks, got {catch_count}: {js}");
assert!(js.contains("document.body.innerText"));
}
#[test]
fn parses_inline_error_envelope() {
let text = "[t1]\nerror EXTENSION_OFFLINE: no active extension websocket for this user";
let parsed = parse_inline_error(text).expect("should parse");
assert_eq!(parsed.code, "EXTENSION_OFFLINE");
assert_eq!(parsed.message, "no active extension websocket for this user");
}
#[test]
fn ignores_non_error_text() {
assert!(parse_inline_error("[t1] https://example.com/\nok browser new-tab").is_none());
}
#[test]
fn error_prefix_for_known_codes() {
assert_eq!(error_prefix_for("EXTENSION_OFFLINE", ""), "EXTENSION_OFFLINE");
assert_eq!(error_prefix_for("CANCELLED", ""), "CANCELLED");
assert_eq!(error_prefix_for("EVAL_FAILED", ""), "RUN_CODE_FAILED");
}
#[test]
fn debugger_attach_conflict_detection() {
assert_eq!(
error_prefix_for("INTERNAL_ERROR", "Cannot access a chrome-extension:// URL of different extension"),
"DEBUGGER_ATTACH_CONFLICT"
);
assert_eq!(
error_prefix_for("INTERNAL_ERROR", "Detached while handling command."),
"DEBUGGER_ATTACH_CONFLICT"
);
assert_eq!(error_prefix_for("INTERNAL_ERROR", "some other internal err"), "INTERNAL_ERROR");
}
#[test]
fn extracts_runcode_payload() {
let raw = "[t1]\nok browser run-code\n{\"result\":{\"url\":\"https://example.com/\",\"title\":\"Example Domain\",\"text\":\"Example Domain\\n\\nHello\"}}";
let p = extract_run_code_payload(raw).expect("should parse");
assert_eq!(p.url, "https://example.com/");
assert_eq!(p.text, "Example Domain\n\nHello");
}
#[test]
fn is_recoverable_handle_loss_matches_two_codes() {
assert!(is_recoverable_handle_loss("SESSION_LOST: foo"));
assert!(is_recoverable_handle_loss("TAB_NOT_FOUND: bar"));
assert!(!is_recoverable_handle_loss("EXTENSION_OFFLINE: baz"));
}
}