use crate::automation::command::{Envelope as AppEnvelope, Reply as AppReply};
use crate::automation::queue::AutomationQueue;
use brep_mcp_core::host::{Backend, Envelope, HostInfo, HostRequest, Reply};
use brep_mcp_core::server::BrepServer;
use serde_json::{json, Value};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
pub const DEFAULT_PORT: u16 = 8765;
const REPLY_TIMEOUT: Duration = Duration::from_secs(120);
pub const WINDOW_PORTS: std::ops::RangeInclusive<u16> = DEFAULT_PORT..=DEFAULT_PORT + 9;
#[derive(Debug)]
pub struct Launch {
pub listener: TcpListener,
pub session_root: PathBuf,
pub note: Option<String>,
}
impl Launch {
pub fn url(&self) -> String {
url(&self.listener)
}
pub fn instructions(&self) -> String {
agent_instructions(&self.url(), &self.session_root)
}
}
pub fn bind(port: u16) -> Result<TcpListener, String> {
TcpListener::bind(("127.0.0.1", port)).map_err(|e| format!("cannot listen on 127.0.0.1:{port}: {e}"))
}
pub fn launch(ports: impl IntoIterator<Item = u16>, session_root: Option<PathBuf>) -> Result<Launch, String> {
let mut failures: Vec<(u16, String)> = Vec::new();
for port in ports {
match bind(port) {
Ok(listener) => {
let bound = listener.local_addr().map(|a| a.port()).unwrap_or(port);
let note = failures
.first()
.map(|(first, _)| format!("port {first} was in use; listening on {bound} instead"));
let session_root = session_root.unwrap_or_else(default_session_root);
return Ok(Launch { listener, session_root, note });
}
Err(e) => failures.push((port, e)),
}
}
match failures.as_slice() {
[] => Err("no port to listen on".to_string()),
[(_, only)] => Err(only.clone()),
[(_, first), .., (last, _)] => Err(format!("{first} (and every port up to {last} is in use too)")),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Status {
Stopped { error: Option<String> },
Running { url: String, session_root: PathBuf, note: Option<String> },
}
static STATUS: Mutex<Status> = Mutex::new(Status::Stopped { error: None });
pub fn status() -> Status {
STATUS.lock().unwrap_or_else(|p| p.into_inner()).clone()
}
fn set_status(status: Status) {
*STATUS.lock().unwrap_or_else(|p| p.into_inner()) = status;
}
fn fail(text: impl std::fmt::Display) -> String {
let line = format!("brep-app --mcp: {text}");
eprintln!("{line}");
set_status(Status::Stopped { error: Some(line.clone()) });
line
}
pub fn info_view() -> crate::panels::info::McpView {
use crate::panels::info::McpView;
match status() {
Status::Stopped { error } => McpView::Stopped { error },
Status::Running { url, session_root, note } => McpView::Running {
instructions: agent_instructions(&url, &session_root),
url,
note,
},
}
}
pub fn start_from_window(queue: Arc<AutomationQueue>, adapter: String) -> Result<(), String> {
start_over(queue, adapter, WINDOW_PORTS)
}
fn start_over(queue: Arc<AutomationQueue>, adapter: String, ports: impl IntoIterator<Item = u16>) -> Result<(), String> {
if let Status::Running { url, .. } = status() {
return Err(format!("the MCP server is already running on {url}"));
}
let launch = match launch(ports, None) {
Ok(launch) => launch,
Err(e) => {
let line = format!("brep-app: {e}");
eprintln!("{line}");
set_status(Status::Stopped { error: Some(line.clone()) });
return Err(line);
}
};
if let Some(note) = &launch.note {
println!("brep-app --mcp: {note}");
}
println!("{}", launch.instructions());
start(queue, adapter, launch).map_err(|e| fail(e))
}
pub fn url(listener: &TcpListener) -> String {
let port = listener.local_addr().map(|a| a.port()).unwrap_or(DEFAULT_PORT);
brep_mcp_core::http::url(port)
}
pub fn default_session_root() -> PathBuf {
std::env::var_os("BREP_MCP_SESSION_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|| std::env::temp_dir().join("brep-mcp"))
}
pub fn agent_instructions(url: &str, session_root: &Path) -> String {
format!(
"BREP MCP server listening on {url}\n\
This window is the session: an agent's tools act on the documents you see here.\n\
\n\
Claude Code:\n\
\x20 claude mcp add --transport http brep {url}\n\
\x20 or in a project's .mcp.json:\n\
\x20 {{ \"mcpServers\": {{ \"brep\": {{ \"type\": \"http\", \"url\": \"{url}\" }} }} }}\n\
\n\
Codex:\n\
\x20 codex mcp add brep --url {url}\n\
\x20 or in ~/.codex/config.toml:\n\
\x20 [mcp_servers.brep]\n\
\x20 url = \"{url}\"\n\
\n\
Then ask the agent to call session_start; screenshots and call logs land under\n\
\x20 {root}\n",
root = session_root.display(),
)
}
fn convert(mut r: AppReply) -> Reply {
let blob = r.blob.take();
let (id, frame) = (r.id, r.frame);
match serde_json::to_value(&r).map_err(|e| e.to_string()).and_then(|v| Reply::from_app(v, blob)) {
Ok(reply) => reply,
Err(e) => Reply::err(id, frame, e),
}
}
async fn forward(queue: &Arc<AutomationQueue>, envelope: Envelope) -> Reply {
let id = envelope.id;
let rx = queue.submit(AppEnvelope { id, cmd: envelope.cmd, args: envelope.args });
match tokio::task::spawn_blocking(move || rx.recv_timeout(REPLY_TIMEOUT)).await {
Ok(Ok(reply)) => convert(reply),
Ok(Err(std::sync::mpsc::RecvTimeoutError::Timeout)) => Reply::err(
id,
queue.frame(),
format!("no reply from the app within {} s (is the window responding?)", REPLY_TIMEOUT.as_secs()),
),
Ok(Err(std::sync::mpsc::RecvTimeoutError::Disconnected)) => Reply::err(id, queue.frame(), "the app dropped the command"),
Err(e) => Reply::err(id, queue.frame(), e.to_string()),
}
}
async fn capture(queue: &Arc<AutomationQueue>, region: Value) -> Result<(Value, Vec<u8>), String> {
let reply = forward(queue, Envelope { id: 0, cmd: "screenshot".into(), args: json!({ "region": region }) }).await;
if !reply.ok {
return Err(reply.error.unwrap_or_else(|| "screenshot failed".into()));
}
let png = reply.blob.ok_or("the screenshot reply carried no image")?;
Ok((reply.result.unwrap_or(Value::Null), png))
}
pub fn start(queue: Arc<AutomationQueue>, adapter: String, launch: Launch) -> Result<(), String> {
{
let mut status = STATUS.lock().unwrap_or_else(|p| p.into_inner());
if let Status::Running { url, .. } = &*status {
return Err(format!("the MCP server is already running on {url}"));
}
*status = Status::Running { url: launch.url(), session_root: launch.session_root.clone(), note: launch.note.clone() };
}
crate::automation::registry::set_enabled(true);
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.thread_name("brep-mcp-worker")
.enable_all()
.build()
.map_err(|e| {
set_status(Status::Stopped { error: None });
format!("tokio runtime: {e}")
})?;
let url = launch.url();
std::thread::Builder::new()
.name("brep-mcp".into())
.spawn(move || {
rt.block_on(async move {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<HostRequest>();
let q = queue.clone();
tokio::spawn(async move {
while let Some(req) = rx.recv().await {
let q = q.clone();
match req {
HostRequest::Command { envelope, reply } => {
tokio::spawn(async move {
let _ = reply.send(forward(&q, envelope).await);
});
}
HostRequest::Screenshot { region, reply } => {
tokio::spawn(async move {
let _ = reply.send(capture(&q, region).await);
});
}
HostRequest::Annotate { reply, .. } => {
let _ = reply.send(Err(
"the window host draws no annotation overlay: docs walkthroughs are recorded by the headless host (brep-mcp)".to_string(),
));
}
HostRequest::Stop => {}
}
}
});
let info = HostInfo {
backend: "window",
adapter,
platform: std::env::consts::OS,
width: 0.0,
height: 0.0,
ppp: 1.0,
};
let server = BrepServer::new(launch.session_root, Backend::Attached { tx, info });
match server.attach(true).await {
Ok(info) => log::info!("brep-app --mcp: attached session {} ({}x{} @ {})", info.id, info.host.width, info.host.height, info.host.ppp),
Err(e) => {
fail(format!("cannot attach the server to the app: {e}"));
return;
}
}
let listener = match launch.listener.set_nonblocking(true).and_then(|_| tokio::net::TcpListener::from_std(launch.listener)) {
Ok(l) => l,
Err(e) => {
fail(format!("listener: {e}"));
return;
}
};
if let Err(e) = brep_mcp_core::http::serve_http(server, listener).await {
fail(format!("server on {url} stopped: {e}"));
}
});
})
.map_err(|e| {
set_status(Status::Stopped { error: None });
format!("spawn the MCP server thread: {e}")
})?;
Ok(())
}