use std::collections::HashMap;
use std::env;
use std::io::BufRead;
use std::io::BufReader;
use std::io::Write;
use std::os::unix::net::UnixStream;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::sync::mpsc;
use std::sync::Arc;
use std::sync::Condvar;
use std::sync::Mutex;
use std::thread;
use std::time::Duration;
use std::time::Instant;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
pub(crate) const SESSION_IDLE_TTL: Duration = Duration::from_secs(30 * 60);
pub(crate) const SESSION_REAPER_INTERVAL: Duration = Duration::from_secs(60);
pub(crate) fn session_idle_ttl() -> Duration {
env::var("CHROME_DEVTOOLS_SESSION_IDLE_TTL_SECS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or(SESSION_IDLE_TTL)
}
pub(crate) fn session_reaper_interval() -> Duration {
env::var("CHROME_DEVTOOLS_SESSION_REAPER_INTERVAL_MS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.map(Duration::from_millis)
.unwrap_or(SESSION_REAPER_INTERVAL)
}
#[derive(Clone, Debug)]
pub(crate) struct PageCleanup {
pub(crate) page_id: u64,
}
#[derive(Clone, Debug)]
pub(crate) struct UidBinding {
pub(crate) page_id: u64,
pub(crate) snapshot_epoch: u64,
pub(crate) raw_uid: String,
}
#[derive(Clone, Debug)]
pub(crate) struct SessionState {
pub(crate) id: String,
pub(crate) created_at: SystemTime,
pub(crate) last_used_at: SystemTime,
pub(crate) owned: bool,
pub(crate) page_id: Option<u64>,
pub(crate) page_created_by_daemon: bool,
pub(crate) page_url: Option<String>,
pub(crate) snapshot_epoch: u64,
pub(crate) uid_bindings: HashMap<String, UidBinding>,
}
#[derive(Default)]
pub(crate) struct SessionRegistry {
pub(crate) sessions: HashMap<String, SessionState>,
}
impl SessionRegistry {
pub(crate) fn create(&mut self) -> SessionState {
let now = SystemTime::now();
let id = generate_session_id();
let state = SessionState {
id: id.clone(),
created_at: now,
last_used_at: now,
owned: false,
page_id: None,
page_created_by_daemon: false,
page_url: None,
snapshot_epoch: 0,
uid_bindings: HashMap::new(),
};
self.sessions.insert(id.clone(), state.clone());
state
}
pub(crate) fn list(&self) -> Vec<SessionState> {
let mut sessions = self.sessions.values().cloned().collect::<Vec<_>>();
sessions.sort_by_key(|session| session.created_at);
sessions
}
pub(crate) fn close(&mut self, id: &str) -> Result<Option<PageCleanup>, String> {
let Some(session) = self.sessions.get(id) else {
return Err(format!("unknown session: {id}"));
};
if session.owned {
return Err(format!("session in use: {id}"));
}
Ok(self.sessions.remove(id).and_then(page_cleanup_for_session))
}
pub(crate) fn bind(&mut self, id: &str) -> Result<(), String> {
let session = self
.sessions
.get_mut(id)
.ok_or_else(|| format!("unknown session: {id}"))?;
if session.owned {
return Err(format!("session in use: {id}"));
}
session.owned = true;
session.last_used_at = SystemTime::now();
Ok(())
}
pub(crate) fn unbind(&mut self, id: &str) {
if let Some(session) = self.sessions.get_mut(id) {
session.owned = false;
session.last_used_at = SystemTime::now();
}
}
pub(crate) fn touch(&mut self, id: &str) {
if let Some(session) = self.sessions.get_mut(id) {
session.last_used_at = SystemTime::now();
}
}
pub(crate) fn reap_expired(&mut self, ttl: Duration) -> Vec<PageCleanup> {
let now = SystemTime::now();
let mut cleanup = Vec::new();
self.sessions.retain(|_, session| {
if session.owned {
return true;
}
let keep = match now.duration_since(session.last_used_at) {
Ok(elapsed) => elapsed < ttl,
Err(_) => true,
};
if !keep {
if let Some(page) = page_cleanup_for_session(session.clone()) {
cleanup.push(page);
}
}
keep
});
cleanup
}
pub(crate) fn page_id(&self, id: &str) -> Option<u64> {
self.sessions.get(id).and_then(|session| session.page_id)
}
pub(crate) fn set_page(
&mut self,
id: &str,
page_id: u64,
page_created_by_daemon: bool,
page_url: Option<String>,
) -> Result<(), String> {
let session = self
.sessions
.get_mut(id)
.ok_or_else(|| format!("unknown session: {id}"))?;
if session.page_id != Some(page_id) {
session.snapshot_epoch = session.snapshot_epoch.wrapping_add(1);
session.uid_bindings.clear();
}
session.page_id = Some(page_id);
session.page_created_by_daemon = page_created_by_daemon;
session.page_url = page_url;
session.last_used_at = SystemTime::now();
Ok(())
}
pub(crate) fn clear_page(&mut self, id: &str) {
if let Some(session) = self.sessions.get_mut(id) {
session.page_id = None;
session.page_created_by_daemon = false;
session.page_url = None;
session.snapshot_epoch = session.snapshot_epoch.wrapping_add(1);
session.uid_bindings.clear();
session.last_used_at = SystemTime::now();
}
}
pub(crate) fn record_snapshot_uids(
&mut self,
id: &str,
page_id: u64,
raw_uids: &[String],
) -> Result<HashMap<String, String>, String> {
let session = self
.sessions
.get_mut(id)
.ok_or_else(|| format!("unknown session: {id}"))?;
session.snapshot_epoch = session.snapshot_epoch.wrapping_add(1);
session.uid_bindings.clear();
let mut replacements = HashMap::new();
for raw_uid in raw_uids {
let token = format!(
"u:{}:{}:{}",
session_short_id(id),
session.snapshot_epoch,
raw_uid
);
replacements.insert(raw_uid.clone(), token.clone());
session.uid_bindings.insert(
token,
UidBinding {
page_id,
snapshot_epoch: session.snapshot_epoch,
raw_uid: raw_uid.clone(),
},
);
}
session.last_used_at = SystemTime::now();
Ok(replacements)
}
pub(crate) fn translate_uid_token(
&self,
id: &str,
page_id: u64,
token: &str,
) -> Result<String, String> {
let session = self
.sessions
.get(id)
.ok_or_else(|| format!("unknown session: {id}"))?;
let Some(rest) = token.strip_prefix("u:") else {
return Err("session uid token is required".to_string());
};
let parts = rest.splitn(3, ':').collect::<Vec<_>>();
if parts.len() != 3 {
return Err("invalid session uid token".to_string());
}
if parts[0] != session_short_id(id) {
return Err("uid token belongs to another session".to_string());
}
let epoch = parts[1]
.parse::<u64>()
.map_err(|_| "invalid session uid token epoch".to_string())?;
if epoch != session.snapshot_epoch {
return Err("stale uid token".to_string());
}
let binding = session
.uid_bindings
.get(token)
.ok_or_else(|| "unknown uid token".to_string())?;
if binding.page_id != page_id {
return Err("uid token belongs to another page".to_string());
}
if binding.snapshot_epoch != session.snapshot_epoch {
return Err("stale uid token".to_string());
}
Ok(binding.raw_uid.clone())
}
}
fn page_cleanup_for_session(session: SessionState) -> Option<PageCleanup> {
if session.page_created_by_daemon {
session.page_id.map(|page_id| PageCleanup { page_id })
} else {
None
}
}
pub(crate) fn generate_session_id() -> String {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos() as u64)
.unwrap_or(0);
let pid = std::process::id() as u64;
let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
let mix = nanos
.wrapping_mul(0x9E3779B97F4A7C15)
.wrapping_add(pid.wrapping_mul(0xBF58476D1CE4E5B9))
.wrapping_add(counter.wrapping_mul(0x94D049BB133111EB));
format!("sess-{mix:016x}")
}
pub(crate) fn unix_secs(time: SystemTime) -> u64 {
time.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0)
}
pub(crate) fn session_short_id(id: &str) -> &str {
id.strip_prefix("sess-").unwrap_or(id)
}
#[derive(Debug)]
pub(crate) enum DaemonError {
Client(String),
Fatal(String),
}
pub(crate) type SharedSessions = Arc<(Mutex<SessionRegistry>, Condvar)>;
#[derive(Clone)]
pub(crate) struct RouterHandle {
sender: mpsc::Sender<RouterRequest>,
queued: Arc<AtomicUsize>,
}
pub(crate) enum RouterRequest {
Forward {
line: String,
session_id: Option<String>,
response: mpsc::Sender<Result<Vec<String>, String>>,
},
ClosePages {
pages: Vec<PageCleanup>,
response: mpsc::Sender<Result<(), String>>,
},
}
impl RouterHandle {
pub(crate) fn start(
mut mcp_stdin: impl Write + Send + 'static,
mut mcp_reader: impl BufRead + Send + 'static,
sessions: SharedSessions,
) -> Self {
let (sender, receiver) = mpsc::channel::<RouterRequest>();
let queued = Arc::new(AtomicUsize::new(0));
let queued_for_router = Arc::clone(&queued);
thread::spawn(move || {
let mut next_id: u64 = 10_000;
for request in receiver {
queued_for_router.fetch_sub(1, Ordering::SeqCst);
match request {
RouterRequest::Forward {
line,
session_id,
response,
} => {
let result = route_request(
&mut mcp_stdin,
&mut mcp_reader,
&sessions,
session_id.as_deref(),
&line,
&mut next_id,
);
let _ = response.send(result);
}
RouterRequest::ClosePages { pages, response } => {
let result = close_daemon_pages(
&mut mcp_stdin,
&mut mcp_reader,
&mut next_id,
pages,
);
let _ = response.send(result);
}
}
}
});
Self { sender, queued }
}
pub(crate) fn forward(
&self,
session_id: Option<&str>,
line: &str,
) -> Result<Vec<String>, DaemonError> {
let (response_tx, response_rx) = mpsc::channel();
self.queued.fetch_add(1, Ordering::SeqCst);
if let Err(error) = self.sender.send(RouterRequest::Forward {
line: line.to_string(),
session_id: session_id.map(|id| id.to_string()),
response: response_tx,
}) {
self.queued.fetch_sub(1, Ordering::SeqCst);
return Err(DaemonError::Fatal(format!(
"failed to queue MCP request: {error}"
)));
}
response_rx
.recv()
.map_err(|error| {
DaemonError::Fatal(format!("failed to receive MCP response: {error}"))
})?
.map_err(DaemonError::Fatal)
}
pub(crate) fn close_pages(&self, pages: Vec<PageCleanup>) -> Result<(), DaemonError> {
if pages.is_empty() {
return Ok(());
}
let (response_tx, response_rx) = mpsc::channel();
self.queued.fetch_add(1, Ordering::SeqCst);
if let Err(error) = self.sender.send(RouterRequest::ClosePages {
pages,
response: response_tx,
}) {
self.queued.fetch_sub(1, Ordering::SeqCst);
return Err(DaemonError::Fatal(format!(
"failed to queue MCP page cleanup: {error}"
)));
}
response_rx
.recv()
.map_err(|error| {
DaemonError::Fatal(format!(
"failed to receive MCP page cleanup response: {error}"
))
})?
.map_err(DaemonError::Fatal)
}
pub(crate) fn queued_requests(&self) -> usize {
self.queued.load(Ordering::SeqCst)
}
}
pub(crate) const PAGE_SCOPED_TOOLS: &[&str] = &[
"click",
"drag",
"emulate",
"evaluate_script",
"fill",
"fill_form",
"get_console_message",
"get_network_request",
"handle_dialog",
"hover",
"lighthouse_audit",
"list_console_messages",
"list_network_requests",
"navigate_page",
"performance_analyze_insight",
"performance_start_trace",
"performance_stop_trace",
"press_key",
"resize_page",
"take_heapsnapshot",
"take_screenshot",
"take_snapshot",
"type_text",
"upload_file",
"wait_for",
];
pub(crate) fn is_page_scoped_tool(name: &str) -> bool {
PAGE_SCOPED_TOOLS.contains(&name)
}
pub(crate) fn forward_line(
mcp_stdin: &mut impl Write,
mcp_reader: &mut impl BufRead,
line: &str,
next_id: &mut u64,
) -> Result<Vec<String>, String> {
let Some(original_id) = extract_jsonrpc_id_value(line) else {
write_json_line(mcp_stdin, line)?;
return Ok(Vec::new());
};
let internal_id = *next_id;
*next_id = next_id.wrapping_add(1);
let forwarded = rewrite_jsonrpc_id(line, serde_json::json!(internal_id))?;
write_json_line(mcp_stdin, &forwarded)?;
let mut lines = Vec::new();
loop {
let response_line =
read_mcp_response_line(mcp_stdin, mcp_reader).map_err(|error| format!("{error:?}"))?;
if extract_jsonrpc_id(&response_line) == Some(internal_id) {
lines.push(rewrite_jsonrpc_id(&response_line, original_id.clone())?);
return Ok(lines);
}
lines.push(response_line);
}
}
fn mcp_call(
mcp_stdin: &mut impl Write,
mcp_reader: &mut impl BufRead,
next_id: &mut u64,
mut request: serde_json::Value,
) -> Result<serde_json::Value, String> {
let internal_id = *next_id;
*next_id = next_id.wrapping_add(1);
request["jsonrpc"] = serde_json::json!("2.0");
request["id"] = serde_json::json!(internal_id);
write_json_line(mcp_stdin, &request.to_string())?;
loop {
let response_line =
read_mcp_response_line(mcp_stdin, mcp_reader).map_err(|error| format!("{error:?}"))?;
if extract_jsonrpc_id(&response_line) == Some(internal_id) {
return serde_json::from_str(&response_line)
.map_err(|error| format!("failed to parse MCP response: {error}"));
}
}
}
fn selected_page_id(response: &serde_json::Value) -> Option<u64> {
let pages = response
.get("result")?
.get("structuredContent")?
.get("pages")?
.as_array()?;
pages
.iter()
.find(|page| page.get("selected").and_then(|value| value.as_bool()) == Some(true))
.and_then(|page| page.get("id"))
.and_then(|id| id.as_u64())
}
fn response_page_ids(response: &serde_json::Value) -> Vec<u64> {
response
.get("result")
.and_then(|result| result.get("structuredContent"))
.and_then(|structured| structured.get("pages"))
.and_then(|pages| pages.as_array())
.map(|pages| {
pages
.iter()
.filter_map(|page| page.get("id").and_then(|id| id.as_u64()))
.collect()
})
.unwrap_or_default()
}
fn list_page_ids(
mcp_stdin: &mut impl Write,
mcp_reader: &mut impl BufRead,
next_id: &mut u64,
) -> Result<Vec<u64>, String> {
let response = mcp_call(
mcp_stdin,
mcp_reader,
next_id,
serde_json::json!({
"method": "tools/call",
"params": {
"name": "list_pages",
"arguments": {}
}
}),
)?;
Ok(response_page_ids(&response))
}
fn close_daemon_pages(
mcp_stdin: &mut impl Write,
mcp_reader: &mut impl BufRead,
next_id: &mut u64,
pages: Vec<PageCleanup>,
) -> Result<(), String> {
for page in pages {
let current = list_page_ids(mcp_stdin, mcp_reader, next_id)?;
if current.len() <= 1 || !current.contains(&page.page_id) {
continue;
}
let _ = mcp_call(
mcp_stdin,
mcp_reader,
next_id,
serde_json::json!({
"method": "tools/call",
"params": {
"name": "close_page",
"arguments": { "pageId": page.page_id }
}
}),
)?;
}
Ok(())
}
fn ensure_session_page(
mcp_stdin: &mut impl Write,
mcp_reader: &mut impl BufRead,
sessions: &SharedSessions,
session_id: &str,
next_id: &mut u64,
) -> Result<u64, String> {
{
let (lock, _) = &**sessions;
let registry = lock
.lock()
.map_err(|_| "session registry poisoned".to_string())?;
if let Some(page_id) = registry.page_id(session_id) {
return Ok(page_id);
}
}
let response = mcp_call(
mcp_stdin,
mcp_reader,
next_id,
serde_json::json!({
"method": "tools/call",
"params": {
"name": "new_page",
"arguments": { "url": "about:blank", "background": true }
}
}),
)?;
let page_id = selected_page_id(&response)
.ok_or_else(|| "failed to determine the allocated page id".to_string())?;
let (lock, _) = &**sessions;
let mut registry = lock
.lock()
.map_err(|_| "session registry poisoned".to_string())?;
registry.set_page(session_id, page_id, true, Some("about:blank".to_string()))?;
Ok(page_id)
}
fn session_page(sessions: &SharedSessions, session_id: &str) -> Option<u64> {
let (lock, _) = &**sessions;
lock.lock()
.ok()
.and_then(|registry| registry.page_id(session_id))
}
fn record_session_page(
sessions: &SharedSessions,
session_id: &str,
page_id: u64,
created_by_daemon: bool,
url: Option<String>,
) -> Result<(), String> {
let (lock, _) = &**sessions;
let mut registry = lock
.lock()
.map_err(|_| "session registry poisoned".to_string())?;
registry.set_page(session_id, page_id, created_by_daemon, url)
}
fn inject_page_id(value: &mut serde_json::Value, page_id: u64) {
let params = value
.as_object_mut()
.and_then(|object| object.get_mut("params"))
.and_then(|params| params.as_object_mut());
let Some(params) = params else {
return;
};
let arguments = params
.entry("arguments")
.or_insert_with(|| serde_json::json!({}));
if let Some(arguments) = arguments.as_object_mut() {
arguments.insert("pageId".to_string(), serde_json::json!(page_id));
}
}
fn strip_page_id_schema(line: &str) -> String {
let Ok(mut value) = serde_json::from_str::<serde_json::Value>(line) else {
return line.to_string();
};
let Some(tools) = value
.get_mut("result")
.and_then(|result| result.get_mut("tools"))
.and_then(|tools| tools.as_array_mut())
else {
return line.to_string();
};
for tool in tools.iter_mut() {
let name = tool
.get("name")
.and_then(|name| name.as_str())
.unwrap_or("")
.to_string();
if !is_page_scoped_tool(&name) {
continue;
}
let Some(schema) = tool.get_mut("inputSchema").and_then(|s| s.as_object_mut()) else {
continue;
};
if let Some(properties) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) {
properties.remove("pageId");
}
if let Some(required) = schema.get_mut("required").and_then(|r| r.as_array_mut()) {
required.retain(|entry| entry.as_str() != Some("pageId"));
}
}
value.to_string()
}
fn rewrite_selected_page(line: &str, page_id: u64) -> String {
let Ok(mut value) = serde_json::from_str::<serde_json::Value>(line) else {
return line.to_string();
};
if let Some(pages) = value
.get_mut("result")
.and_then(|result| result.get_mut("structuredContent"))
.and_then(|structured| structured.get_mut("pages"))
.and_then(|pages| pages.as_array_mut())
{
for page in pages.iter_mut() {
let is_target = page.get("id").and_then(|id| id.as_u64()) == Some(page_id);
if let Some(object) = page.as_object_mut() {
object.insert("selected".to_string(), serde_json::json!(is_target));
}
}
}
value.to_string()
}
fn collect_raw_uids(value: &serde_json::Value, out: &mut Vec<String>) {
match value {
serde_json::Value::Object(map) => {
for (key, value) in map {
if key == "uid" {
if let Some(raw_uid) = value.as_str() {
push_unique(out, raw_uid);
}
} else {
collect_raw_uids(value, out);
}
}
}
serde_json::Value::Array(items) => {
for item in items {
collect_raw_uids(item, out);
}
}
serde_json::Value::String(text) => {
collect_text_uids(text, out);
}
_ => {}
}
}
fn push_unique(out: &mut Vec<String>, value: &str) {
if !out.iter().any(|item| item == value) {
out.push(value.to_string());
}
}
fn collect_text_uids(text: &str, out: &mut Vec<String>) {
let mut rest = text;
while let Some(index) = rest.find("uid=") {
let after = &rest[index + 4..];
let end = after
.find(|character: char| {
character.is_whitespace()
|| matches!(character, ')' | ']' | '}' | ',' | ';' | '"' | '\'')
})
.unwrap_or(after.len());
if end > 0 {
push_unique(out, &after[..end]);
}
rest = &after[end..];
}
}
fn replace_raw_uids(value: &mut serde_json::Value, replacements: &HashMap<String, String>) {
match value {
serde_json::Value::Object(map) => {
for (key, value) in map {
if key == "uid" {
if let Some(raw_uid) = value.as_str() {
if let Some(token) = replacements.get(raw_uid) {
*value = serde_json::Value::String(token.clone());
}
}
} else {
replace_raw_uids(value, replacements);
}
}
}
serde_json::Value::Array(items) => {
for item in items {
replace_raw_uids(item, replacements);
}
}
serde_json::Value::String(text) => {
*text = replace_text_uids(text, replacements);
}
_ => {}
}
}
fn replace_text_uids(text: &str, replacements: &HashMap<String, String>) -> String {
let mut output = String::new();
let mut rest = text;
while let Some(index) = rest.find("uid=") {
output.push_str(&rest[..index + 4]);
let after = &rest[index + 4..];
let end = after
.find(|character: char| {
character.is_whitespace()
|| matches!(character, ')' | ']' | '}' | ',' | ';' | '"' | '\'')
})
.unwrap_or(after.len());
let raw_uid = &after[..end];
if let Some(token) = replacements.get(raw_uid) {
output.push_str(token);
} else {
output.push_str(raw_uid);
}
rest = &after[end..];
}
output.push_str(rest);
output
}
fn rewrite_uid_response(
line: &str,
sessions: &SharedSessions,
session_id: &str,
page_id: u64,
) -> Result<String, String> {
let Ok(mut value) = serde_json::from_str::<serde_json::Value>(line) else {
return Ok(line.to_string());
};
let mut raw_uids = Vec::new();
collect_raw_uids(&value, &mut raw_uids);
if raw_uids.is_empty() {
return Ok(line.to_string());
}
let replacements = {
let (lock, _) = &**sessions;
let mut registry = lock
.lock()
.map_err(|_| "session registry poisoned".to_string())?;
registry.record_snapshot_uids(session_id, page_id, &raw_uids)?
};
replace_raw_uids(&mut value, &replacements);
Ok(value.to_string())
}
fn rewrite_uid_responses(
lines: Vec<String>,
sessions: &SharedSessions,
session_id: &str,
page_id: u64,
) -> Result<Vec<String>, String> {
lines
.into_iter()
.map(|line| rewrite_uid_response(&line, sessions, session_id, page_id))
.collect()
}
fn translate_uid_fields(
arguments: &mut serde_json::Value,
sessions: &SharedSessions,
session_id: &str,
page_id: u64,
) -> Result<(), String> {
let (lock, _) = &**sessions;
let registry = lock
.lock()
.map_err(|_| "session registry poisoned".to_string())?;
translate_uid_key(arguments, "uid", ®istry, session_id, page_id)?;
translate_uid_key(arguments, "from_uid", ®istry, session_id, page_id)?;
translate_uid_key(arguments, "to_uid", ®istry, session_id, page_id)?;
if let Some(elements) = arguments
.get_mut("elements")
.and_then(|elements| elements.as_array_mut())
{
for element in elements {
translate_uid_key(element, "uid", ®istry, session_id, page_id)?;
}
}
if let Some(args) = arguments
.get_mut("args")
.and_then(|args| args.as_array_mut())
{
for arg in args {
if let Some(token) = arg.as_str() {
*arg = serde_json::Value::String(
registry.translate_uid_token(session_id, page_id, token)?,
);
}
}
}
Ok(())
}
fn translate_uid_key(
arguments: &mut serde_json::Value,
key: &str,
registry: &SessionRegistry,
session_id: &str,
page_id: u64,
) -> Result<(), String> {
if let Some(value) = arguments.get_mut(key) {
if let Some(token) = value.as_str() {
*value = serde_json::Value::String(
registry.translate_uid_token(session_id, page_id, token)?,
);
}
}
Ok(())
}
fn request_error_line(value: &serde_json::Value, message: &str) -> Vec<String> {
value
.get("id")
.cloned()
.map(|id| vec![jsonrpc_error_response(id, -32000, message)])
.unwrap_or_default()
}
#[allow(clippy::too_many_arguments)]
fn route_request(
mcp_stdin: &mut impl Write,
mcp_reader: &mut impl BufRead,
sessions: &SharedSessions,
session_id: Option<&str>,
line: &str,
next_id: &mut u64,
) -> Result<Vec<String>, String> {
let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
return forward_line(mcp_stdin, mcp_reader, line, next_id);
};
let method = value.get("method").and_then(|m| m.as_str()).unwrap_or("");
if method == "tools/list" {
let mut lines = forward_line(mcp_stdin, mcp_reader, line, next_id)?;
if let Some(last) = lines.last_mut() {
*last = strip_page_id_schema(last);
}
return Ok(lines);
}
if method != "tools/call" {
return forward_line(mcp_stdin, mcp_reader, line, next_id);
}
let name = value
.get("params")
.and_then(|params| params.get("name"))
.and_then(|name| name.as_str())
.unwrap_or("")
.to_string();
match name.as_str() {
"new_page" => route_new_page(mcp_stdin, mcp_reader, sessions, session_id, value, next_id),
"list_pages" => {
let lines = forward_line(mcp_stdin, mcp_reader, line, next_id)?;
Ok(annotate_session_page(
lines,
session_id.and_then(|id| session_page(sessions, id)),
))
}
"select_page" => route_select_page(
mcp_stdin, mcp_reader, sessions, session_id, &value, line, next_id,
),
"close_page" => {
route_close_page(mcp_stdin, mcp_reader, sessions, session_id, &value, next_id)
}
other if is_page_scoped_tool(other) => {
let Some(session_id) = session_id else {
return Err("page-scoped tool requires a bound session".to_string());
};
let page_id =
ensure_session_page(mcp_stdin, mcp_reader, sessions, session_id, next_id)?;
let mut forwarded = value;
inject_page_id(&mut forwarded, page_id);
if let Some(arguments) = forwarded
.get_mut("params")
.and_then(|params| params.get_mut("arguments"))
{
if let Err(message) = translate_uid_fields(arguments, sessions, session_id, page_id)
{
return Ok(request_error_line(&forwarded, &message));
}
}
let lines = forward_line(mcp_stdin, mcp_reader, &forwarded.to_string(), next_id)?;
rewrite_uid_responses(lines, sessions, session_id, page_id)
}
_ => forward_line(mcp_stdin, mcp_reader, line, next_id),
}
}
fn annotate_session_page(mut lines: Vec<String>, page_id: Option<u64>) -> Vec<String> {
if let (Some(last), Some(page_id)) = (lines.last_mut(), page_id) {
*last = rewrite_selected_page(last, page_id);
}
lines
}
fn route_new_page(
mcp_stdin: &mut impl Write,
mcp_reader: &mut impl BufRead,
sessions: &SharedSessions,
session_id: Option<&str>,
value: serde_json::Value,
next_id: &mut u64,
) -> Result<Vec<String>, String> {
let mut forwarded = value;
let url = forwarded
.get("params")
.and_then(|params| params.get("arguments"))
.and_then(|arguments| arguments.get("url"))
.and_then(|url| url.as_str())
.map(|url| url.to_string());
if let Some(arguments) = forwarded
.get_mut("params")
.and_then(|params| params.as_object_mut())
.map(|params| {
params
.entry("arguments")
.or_insert_with(|| serde_json::json!({}))
})
.and_then(|arguments| arguments.as_object_mut())
{
arguments
.entry("background".to_string())
.or_insert_with(|| serde_json::json!(true));
}
let lines = forward_line(mcp_stdin, mcp_reader, &forwarded.to_string(), next_id)?;
if let (Some(session_id), Some(last)) = (session_id, lines.last()) {
if let Ok(response) = serde_json::from_str::<serde_json::Value>(last) {
if let Some(page_id) = selected_page_id(&response) {
let _ = record_session_page(sessions, session_id, page_id, true, url);
}
}
}
Ok(lines)
}
fn route_select_page(
mcp_stdin: &mut impl Write,
mcp_reader: &mut impl BufRead,
sessions: &SharedSessions,
session_id: Option<&str>,
value: &serde_json::Value,
line: &str,
next_id: &mut u64,
) -> Result<Vec<String>, String> {
let requested = value
.get("params")
.and_then(|params| params.get("arguments"))
.and_then(|arguments| arguments.get("pageId"))
.and_then(|page_id| page_id.as_u64());
let lines = forward_line(mcp_stdin, mcp_reader, line, next_id)?;
if let (Some(session_id), Some(page_id)) = (session_id, requested) {
let errored = lines
.last()
.and_then(|last| serde_json::from_str::<serde_json::Value>(last).ok())
.map(|response| response.get("error").is_some())
.unwrap_or(false);
if !errored {
let _ = record_session_page(sessions, session_id, page_id, false, None);
}
}
Ok(lines)
}
fn route_close_page(
mcp_stdin: &mut impl Write,
mcp_reader: &mut impl BufRead,
sessions: &SharedSessions,
session_id: Option<&str>,
value: &serde_json::Value,
next_id: &mut u64,
) -> Result<Vec<String>, String> {
let mut forwarded = value.clone();
let requested = forwarded
.get("params")
.and_then(|params| params.get("arguments"))
.and_then(|arguments| arguments.get("pageId"))
.and_then(|page_id| page_id.as_u64());
let page_id = match (session_id, requested) {
(_, Some(page_id)) => Some(page_id),
(Some(session_id), None) => {
let page_id =
ensure_session_page(mcp_stdin, mcp_reader, sessions, session_id, next_id)?;
inject_page_id(&mut forwarded, page_id);
Some(page_id)
}
_ => None,
};
let lines = forward_line(mcp_stdin, mcp_reader, &forwarded.to_string(), next_id)?;
if let (Some(session_id), Some(page_id)) = (session_id, page_id) {
if session_page(sessions, session_id) == Some(page_id) {
let (lock, _) = &**sessions;
if let Ok(mut registry) = lock.lock() {
registry.clear_page(session_id);
}
}
}
Ok(lines)
}
pub(crate) struct BoundSessionGuard<'a> {
sessions: &'a SharedSessions,
id: Option<String>,
bound_at: Instant,
}
impl<'a> BoundSessionGuard<'a> {
fn new(sessions: &'a SharedSessions) -> Self {
Self {
sessions,
id: None,
bound_at: Instant::now(),
}
}
fn mark_bound(&mut self, id: String) {
self.id = Some(id);
self.bound_at = Instant::now();
}
}
impl Drop for BoundSessionGuard<'_> {
fn drop(&mut self) {
if let Some(id) = self.id.take() {
eprintln!(
"bind session={id} held_ms={}",
self.bound_at.elapsed().as_millis()
);
let (lock, cvar) = &**self.sessions;
if let Ok(mut registry) = lock.lock() {
registry.unbind(&id);
cvar.notify_all();
}
}
}
}
pub(crate) fn handle_daemon_client(
mut stream: UnixStream,
router: RouterHandle,
sessions: &SharedSessions,
mcp_port: u16,
) -> Result<bool, DaemonError> {
let mut client_reader = BufReader::new(stream.try_clone().map_err(|error| {
DaemonError::Client(format!("failed to clone daemon client stream: {error}"))
})?);
let mut line = String::new();
let mut bound = BoundSessionGuard::new(sessions);
loop {
line.clear();
let bytes = client_reader.read_line(&mut line).map_err(|error| {
DaemonError::Client(format!("failed to read daemon client request: {error}"))
})?;
if bytes == 0 {
return Ok(false);
}
let line = line.trim_end();
if line.is_empty() {
continue;
}
if let Some(command) = line.strip_prefix("__chrome_devtools_daemon__:") {
match handle_control_command(
&mut stream,
&router,
sessions,
&mut bound,
command,
mcp_port,
)? {
ControlOutcome::Continue => continue,
ControlOutcome::CloseConnection => return Ok(false),
ControlOutcome::StopDaemon => return Ok(true),
}
}
if json_has_method(line, "initialize") {
if let Some(id) = extract_jsonrpc_id_value(line) {
let response = daemon_initialize_response(id);
stream
.write_all(response.as_bytes())
.and_then(|_| stream.write_all(b"\n"))
.and_then(|_| stream.flush())
.map_err(|error| {
DaemonError::Client(format!(
"failed to write daemon initialize response: {error}"
))
})?;
}
continue;
}
if json_has_method(line, "notifications/initialized") {
continue;
}
let forwarded = sanitize_outgoing_request(line);
if bound.id.is_none() && !json_has_method(&forwarded, "tools/list") {
if let Some(id) = extract_jsonrpc_id_value(&forwarded) {
let response =
jsonrpc_error_response(id, -32000, "session bind required for MCP forwarding");
stream
.write_all(response.as_bytes())
.and_then(|_| stream.write_all(b"\n"))
.and_then(|_| stream.flush())
.map_err(|error| {
DaemonError::Client(format!(
"failed to write daemon client response: {error}"
))
})?;
}
continue;
}
for response_line in router.forward(bound.id.as_deref(), &forwarded)? {
stream
.write_all(response_line.as_bytes())
.and_then(|_| stream.write_all(b"\n"))
.and_then(|_| stream.flush())
.map_err(|error| {
DaemonError::Client(format!("failed to write daemon client response: {error}"))
})?;
if let Some(id) = bound.id.as_ref() {
let (lock, _) = &**sessions;
if let Ok(mut registry) = lock.lock() {
registry.touch(id);
}
}
}
}
}
pub(crate) fn read_mcp_response_line(
mcp_stdin: &mut impl Write,
mcp_reader: &mut impl BufRead,
) -> Result<String, DaemonError> {
loop {
let mut response_line = String::new();
let bytes = mcp_reader
.read_line(&mut response_line)
.map_err(|error| DaemonError::Fatal(format!("failed to read MCP response: {error}")))?;
if bytes == 0 {
return Err(DaemonError::Fatal(
"chrome-devtools-mcp closed stdout before responding".to_string(),
));
}
let response_line = response_line.trim_end().to_string();
if json_has_method(&response_line, "roots/list") {
if let Some(id) = extract_jsonrpc_id(&response_line) {
write_json_line(mcp_stdin, &roots_list_response(id)).map_err(DaemonError::Fatal)?;
}
continue;
}
return Ok(response_line);
}
}
#[cfg(test)]
pub(crate) fn drain_pending_mcp_response(
mcp_stdin: &mut impl Write,
mcp_reader: &mut impl BufRead,
pending_id: u64,
already_read_line: &str,
) -> Result<(), DaemonError> {
if extract_jsonrpc_id(already_read_line) == Some(pending_id) {
return Ok(());
}
loop {
let response_line = read_mcp_response_line(mcp_stdin, mcp_reader)?;
if extract_jsonrpc_id(&response_line) == Some(pending_id) {
return Ok(());
}
}
}
pub(crate) enum ControlOutcome {
Continue,
CloseConnection,
StopDaemon,
}
pub(crate) fn handle_control_command(
stream: &mut UnixStream,
router: &RouterHandle,
sessions: &SharedSessions,
bound: &mut BoundSessionGuard,
command: &str,
mcp_port: u16,
) -> Result<ControlOutcome, DaemonError> {
let (head, rest) = match command.split_once(' ') {
Some((head, rest)) => (head, rest.trim()),
None => (command, ""),
};
match head {
"status" => {
let snapshot = lock_sessions(sessions)?.list();
let count = snapshot.len();
let active = snapshot.iter().filter(|session| session.owned).count();
let pages = snapshot
.iter()
.filter_map(|session| session.page_id.map(|page| page.to_string()))
.collect::<Vec<_>>()
.join(",");
write_control_line(
stream,
&format!(
"daemon=ready version={} sessions={count} active_sessions={active} pages={pages} queued_mcp_requests={} mcp_port={mcp_port}",
env!("CARGO_PKG_VERSION"),
router.queued_requests()
),
)?;
Ok(ControlOutcome::CloseConnection)
}
"stop" => {
if rest != "force" {
let count = lock_sessions(sessions)?.list().len();
if count > 0 {
write_control_line(
stream,
&format!(
"error={count} active session(s); other agents may be using this daemon, pass --force to stop anyway"
),
)?;
return Ok(ControlOutcome::CloseConnection);
}
}
write_control_line(stream, "daemon=stopping")?;
Ok(ControlOutcome::StopDaemon)
}
"session_create" => {
let state = lock_sessions(sessions)?.create();
write_control_line(stream, &format_session_line(&state))?;
Ok(ControlOutcome::CloseConnection)
}
"session_list" => {
let snapshot = lock_sessions(sessions)?.list();
for state in &snapshot {
write_control_line(stream, &format_session_line(state))?;
}
Ok(ControlOutcome::CloseConnection)
}
"session_close" => {
let id = parse_session_arg(rest).map_err(DaemonError::Client)?;
let result = lock_sessions(sessions)?.close(&id);
match result {
Ok(cleanup) => {
if let Some(cleanup) = cleanup {
router.close_pages(vec![cleanup])?;
}
write_control_line(stream, &format!("closed={id}"))?;
}
Err(message) => write_control_line(stream, &format!("error={message}"))?,
}
Ok(ControlOutcome::CloseConnection)
}
"session_page" => {
let id = parse_session_arg(rest).map_err(DaemonError::Client)?;
let snapshot = lock_sessions(sessions)?;
let page = snapshot
.sessions
.get(&id)
.ok_or_else(|| DaemonError::Client(format!("unknown session: {id}")))?
.page_id
.map(|page_id| page_id.to_string())
.unwrap_or_default();
write_control_line(stream, &format!("session={id} page={page}"))?;
Ok(ControlOutcome::CloseConnection)
}
"session_attach" => {
let id = parse_session_arg(rest).map_err(DaemonError::Client)?;
let page_id = parse_page_arg(rest).map_err(DaemonError::Client)?;
let result = lock_sessions(sessions)?.set_page(&id, page_id, false, None);
match result {
Ok(()) => write_control_line(stream, &format!("session={id} page={page_id}"))?,
Err(message) => write_control_line(stream, &format!("error={message}"))?,
}
Ok(ControlOutcome::CloseConnection)
}
"bind" => {
let id = parse_session_arg(rest).map_err(DaemonError::Client)?;
let result = bind_session_in_registry(sessions, &id);
match result {
Ok(()) => {
bound.mark_bound(id.clone());
write_control_line(stream, &format!("bound={id}"))?;
Ok(ControlOutcome::Continue)
}
Err(message) => {
write_control_line(stream, &format!("error={message}"))?;
Ok(ControlOutcome::CloseConnection)
}
}
}
other => {
write_control_line(stream, &format!("error=unknown command: {other}"))?;
Ok(ControlOutcome::CloseConnection)
}
}
}
pub(crate) fn lock_sessions<'a>(
sessions: &'a SharedSessions,
) -> Result<std::sync::MutexGuard<'a, SessionRegistry>, DaemonError> {
let (lock, _) = &**sessions;
lock.lock()
.map_err(|_| DaemonError::Fatal("session registry poisoned".to_string()))
}
pub(crate) fn bind_session_in_registry(sessions: &SharedSessions, id: &str) -> Result<(), String> {
let (lock, _) = &**sessions;
let mut registry = lock
.lock()
.map_err(|_| "session registry poisoned".to_string())?;
registry.bind(id)
}
pub(crate) fn write_control_line(stream: &mut UnixStream, body: &str) -> Result<(), DaemonError> {
stream
.write_all(body.as_bytes())
.and_then(|_| stream.write_all(b"\n"))
.and_then(|_| stream.flush())
.map_err(|error| DaemonError::Client(format!("failed to write daemon response: {error}")))
}
pub(crate) fn format_session_line(state: &SessionState) -> String {
let page = state
.page_id
.map(|page_id| page_id.to_string())
.unwrap_or_default();
let page_url = state.page_url.as_deref().unwrap_or("");
format!(
"session={} created={} last_used={} owned={} page={} page_created_by_daemon={} page_url={} snapshot_epoch={}",
state.id,
unix_secs(state.created_at),
unix_secs(state.last_used_at),
state.owned,
page,
state.page_created_by_daemon,
page_url,
state.snapshot_epoch
)
}
pub(crate) fn parse_session_arg(args: &str) -> Result<String, String> {
for part in args.split_whitespace() {
if let Some(value) = part.strip_prefix("session=") {
if value.is_empty() {
return Err("session id must not be empty".to_string());
}
return Ok(value.to_string());
}
}
Err("missing session=<id> argument".to_string())
}
pub(crate) fn parse_page_arg(args: &str) -> Result<u64, String> {
for part in args.split_whitespace() {
if let Some(value) = part.strip_prefix("page=") {
return value
.parse::<u64>()
.map_err(|_| "page id must be an integer".to_string());
}
}
Err("missing page=<id> argument".to_string())
}
pub(crate) fn write_json_line(stdin: &mut impl Write, json: &str) -> Result<(), String> {
stdin
.write_all(json.as_bytes())
.and_then(|_| stdin.write_all(b"\n"))
.and_then(|_| stdin.flush())
.map_err(|error| format!("failed to write MCP request: {error}"))
}
pub(crate) fn read_response(
reader: &mut impl BufRead,
stdin: &mut impl Write,
target_id: u64,
) -> Result<String, String> {
loop {
let mut line = String::new();
let bytes = reader
.read_line(&mut line)
.map_err(|error| format!("failed to read MCP response: {error}"))?;
if bytes == 0 {
return Err("chrome-devtools-mcp closed stdout before responding".to_string());
}
let line = line.trim_end().to_string();
if json_has_method(&line, "roots/list") {
if let Some(id) = extract_jsonrpc_id(&line) {
write_json_line(stdin, &roots_list_response(id))?;
}
continue;
}
if extract_jsonrpc_id(&line) == Some(target_id) {
return Ok(line);
}
}
}
pub(crate) fn roots_list_response(id: impl Into<serde_json::Value>) -> String {
let home = env::var("HOME").unwrap_or_default();
serde_json::json!({
"jsonrpc": "2.0",
"id": id.into(),
"result": {
"roots": [{
"uri": format!("file://{home}"),
"name": "home"
}]
}
})
.to_string()
}
pub(crate) fn daemon_initialize_response(id: impl Into<serde_json::Value>) -> String {
serde_json::json!({
"jsonrpc": "2.0",
"id": id.into(),
"result": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"serverInfo": {
"name": "chrome-devtools-daemon",
"version": "0.1.0"
}
}
})
.to_string()
}
pub(crate) fn jsonrpc_error_response(
id: impl Into<serde_json::Value>,
code: i64,
message: &str,
) -> String {
serde_json::json!({
"jsonrpc": "2.0",
"id": id.into(),
"error": {
"code": code,
"message": message
}
})
.to_string()
}
pub(crate) fn sanitize_outgoing_request(line: &str) -> String {
let Ok(mut value) = serde_json::from_str::<serde_json::Value>(line) else {
return line.to_string();
};
let Some(obj) = value.as_object_mut() else {
return line.to_string();
};
let method_is_tools_call = obj
.get("method")
.and_then(|m| m.as_str())
.map(|m| m == "tools/call")
.unwrap_or(false);
if !method_is_tools_call {
return line.to_string();
}
let Some(params) = obj.get_mut("params").and_then(|p| p.as_object_mut()) else {
return line.to_string();
};
let name_is_new_page = params
.get("name")
.and_then(|n| n.as_str())
.map(|n| n == "new_page")
.unwrap_or(false);
if !name_is_new_page {
return line.to_string();
}
let Some(args) = params.get_mut("arguments").and_then(|a| a.as_object_mut()) else {
return line.to_string();
};
if args.remove("isolatedContext").is_none() {
return line.to_string();
}
eprintln!(
"warning: stripped 'isolatedContext' from new_page; isolated browser contexts disable extensions"
);
serde_json::to_string(&value).unwrap_or_else(|_| line.to_string())
}
pub(crate) fn json_has_method(line: &str, method: &str) -> bool {
serde_json::from_str::<serde_json::Value>(line)
.ok()
.and_then(|value| {
value
.get("method")
.and_then(|found| found.as_str())
.map(|found| found == method)
})
.unwrap_or(false)
}
pub(crate) fn extract_jsonrpc_id(line: &str) -> Option<u64> {
serde_json::from_str::<serde_json::Value>(line)
.ok()?
.get("id")?
.as_u64()
}
pub(crate) fn extract_jsonrpc_id_value(line: &str) -> Option<serde_json::Value> {
serde_json::from_str::<serde_json::Value>(line)
.ok()?
.get("id")
.cloned()
}
pub(crate) fn rewrite_jsonrpc_id(line: &str, id: serde_json::Value) -> Result<String, String> {
let mut value = serde_json::from_str::<serde_json::Value>(line)
.map_err(|error| format!("failed to parse JSON-RPC message: {error}"))?;
let Some(object) = value.as_object_mut() else {
return Err("JSON-RPC message must be an object".to_string());
};
object.insert("id".to_string(), id);
Ok(value.to_string())
}