1use crate::automation::command::{Envelope as AppEnvelope, Reply as AppReply};
15use crate::automation::queue::AutomationQueue;
16use brep_mcp_core::host::{Backend, Envelope, HostInfo, HostRequest, Reply};
17use brep_mcp_core::server::BrepServer;
18use serde_json::{json, Value};
19use std::net::TcpListener;
20use std::path::{Path, PathBuf};
21use std::sync::{Arc, Mutex};
22use std::time::Duration;
23
24pub const DEFAULT_PORT: u16 = 8765;
26
27const REPLY_TIMEOUT: Duration = Duration::from_secs(120);
31
32pub const WINDOW_PORTS: std::ops::RangeInclusive<u16> = DEFAULT_PORT..=DEFAULT_PORT + 9;
38
39#[derive(Debug)]
43pub struct Launch {
44 pub listener: TcpListener,
45 pub session_root: PathBuf,
46 pub note: Option<String>,
49}
50
51impl Launch {
52 pub fn url(&self) -> String {
53 url(&self.listener)
54 }
55
56 pub fn instructions(&self) -> String {
59 agent_instructions(&self.url(), &self.session_root)
60 }
61}
62
63pub fn bind(port: u16) -> Result<TcpListener, String> {
66 TcpListener::bind(("127.0.0.1", port)).map_err(|e| format!("cannot listen on 127.0.0.1:{port}: {e}"))
67}
68
69pub fn launch(ports: impl IntoIterator<Item = u16>, session_root: Option<PathBuf>) -> Result<Launch, String> {
75 let mut failures: Vec<(u16, String)> = Vec::new();
76 for port in ports {
77 match bind(port) {
78 Ok(listener) => {
79 let bound = listener.local_addr().map(|a| a.port()).unwrap_or(port);
82 let note = failures
83 .first()
84 .map(|(first, _)| format!("port {first} was in use; listening on {bound} instead"));
85 let session_root = session_root.unwrap_or_else(default_session_root);
86 return Ok(Launch { listener, session_root, note });
87 }
88 Err(e) => failures.push((port, e)),
89 }
90 }
91 match failures.as_slice() {
92 [] => Err("no port to listen on".to_string()),
93 [(_, only)] => Err(only.clone()),
94 [(_, first), .., (last, _)] => Err(format!("{first} (and every port up to {last} is in use too)")),
95 }
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum Status {
104 Stopped { error: Option<String> },
107 Running { url: String, session_root: PathBuf, note: Option<String> },
109}
110
111static STATUS: Mutex<Status> = Mutex::new(Status::Stopped { error: None });
112
113pub fn status() -> Status {
114 STATUS.lock().unwrap_or_else(|p| p.into_inner()).clone()
115}
116
117fn set_status(status: Status) {
118 *STATUS.lock().unwrap_or_else(|p| p.into_inner()) = status;
119}
120
121fn fail(text: impl std::fmt::Display) -> String {
125 let line = format!("brep-app --mcp: {text}");
126 eprintln!("{line}");
127 set_status(Status::Stopped { error: Some(line.clone()) });
128 line
129}
130
131pub fn info_view() -> crate::panels::info::McpView {
133 use crate::panels::info::McpView;
134 match status() {
135 Status::Stopped { error } => McpView::Stopped { error },
136 Status::Running { url, session_root, note } => McpView::Running {
137 instructions: agent_instructions(&url, &session_root),
138 url,
139 note,
140 },
141 }
142}
143
144pub fn start_from_window(queue: Arc<AutomationQueue>, adapter: String) -> Result<(), String> {
150 start_over(queue, adapter, WINDOW_PORTS)
151}
152
153fn start_over(queue: Arc<AutomationQueue>, adapter: String, ports: impl IntoIterator<Item = u16>) -> Result<(), String> {
154 if let Status::Running { url, .. } = status() {
158 return Err(format!("the MCP server is already running on {url}"));
159 }
160 let launch = match launch(ports, None) {
161 Ok(launch) => launch,
162 Err(e) => {
164 let line = format!("brep-app: {e}");
165 eprintln!("{line}");
166 set_status(Status::Stopped { error: Some(line.clone()) });
167 return Err(line);
168 }
169 };
170 if let Some(note) = &launch.note {
171 println!("brep-app --mcp: {note}");
172 }
173 println!("{}", launch.instructions());
174 start(queue, adapter, launch).map_err(|e| fail(e))
175}
176
177pub fn url(listener: &TcpListener) -> String {
178 let port = listener.local_addr().map(|a| a.port()).unwrap_or(DEFAULT_PORT);
179 brep_mcp_core::http::url(port)
180}
181
182pub fn default_session_root() -> PathBuf {
185 std::env::var_os("BREP_MCP_SESSION_ROOT")
186 .map(PathBuf::from)
187 .unwrap_or_else(|| std::env::temp_dir().join("brep-mcp"))
188}
189
190pub fn agent_instructions(url: &str, session_root: &Path) -> String {
193 format!(
194 "BREP MCP server listening on {url}\n\
195 This window is the session: an agent's tools act on the documents you see here.\n\
196 \n\
197 Claude Code:\n\
198 \x20 claude mcp add --transport http brep {url}\n\
199 \x20 or in a project's .mcp.json:\n\
200 \x20 {{ \"mcpServers\": {{ \"brep\": {{ \"type\": \"http\", \"url\": \"{url}\" }} }} }}\n\
201 \n\
202 Codex:\n\
203 \x20 codex mcp add brep --url {url}\n\
204 \x20 or in ~/.codex/config.toml:\n\
205 \x20 [mcp_servers.brep]\n\
206 \x20 url = \"{url}\"\n\
207 \n\
208 Then ask the agent to call session_start; screenshots and call logs land under\n\
209 \x20 {root}\n",
210 root = session_root.display(),
211 )
212}
213
214fn convert(mut r: AppReply) -> Reply {
216 let blob = r.blob.take();
217 let (id, frame) = (r.id, r.frame);
218 match serde_json::to_value(&r).map_err(|e| e.to_string()).and_then(|v| Reply::from_app(v, blob)) {
219 Ok(reply) => reply,
220 Err(e) => Reply::err(id, frame, e),
221 }
222}
223
224async fn forward(queue: &Arc<AutomationQueue>, envelope: Envelope) -> Reply {
226 let id = envelope.id;
227 let rx = queue.submit(AppEnvelope { id, cmd: envelope.cmd, args: envelope.args });
228 match tokio::task::spawn_blocking(move || rx.recv_timeout(REPLY_TIMEOUT)).await {
229 Ok(Ok(reply)) => convert(reply),
230 Ok(Err(std::sync::mpsc::RecvTimeoutError::Timeout)) => Reply::err(
231 id,
232 queue.frame(),
233 format!("no reply from the app within {} s (is the window responding?)", REPLY_TIMEOUT.as_secs()),
234 ),
235 Ok(Err(std::sync::mpsc::RecvTimeoutError::Disconnected)) => Reply::err(id, queue.frame(), "the app dropped the command"),
236 Err(e) => Reply::err(id, queue.frame(), e.to_string()),
237 }
238}
239
240async fn capture(queue: &Arc<AutomationQueue>, region: Value) -> Result<(Value, Vec<u8>), String> {
243 let reply = forward(queue, Envelope { id: 0, cmd: "screenshot".into(), args: json!({ "region": region }) }).await;
244 if !reply.ok {
245 return Err(reply.error.unwrap_or_else(|| "screenshot failed".into()));
246 }
247 let png = reply.blob.ok_or("the screenshot reply carried no image")?;
248 Ok((reply.result.unwrap_or(Value::Null), png))
249}
250
251pub fn start(queue: Arc<AutomationQueue>, adapter: String, launch: Launch) -> Result<(), String> {
258 {
259 let mut status = STATUS.lock().unwrap_or_else(|p| p.into_inner());
260 if let Status::Running { url, .. } = &*status {
261 return Err(format!("the MCP server is already running on {url}"));
262 }
263 *status = Status::Running { url: launch.url(), session_root: launch.session_root.clone(), note: launch.note.clone() };
264 }
265 crate::automation::registry::set_enabled(true);
270 let rt = tokio::runtime::Builder::new_multi_thread()
271 .worker_threads(2)
272 .thread_name("brep-mcp-worker")
273 .enable_all()
274 .build()
275 .map_err(|e| {
276 set_status(Status::Stopped { error: None });
277 format!("tokio runtime: {e}")
278 })?;
279 let url = launch.url();
280 std::thread::Builder::new()
281 .name("brep-mcp".into())
282 .spawn(move || {
283 rt.block_on(async move {
284 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<HostRequest>();
285 let q = queue.clone();
286 tokio::spawn(async move {
287 while let Some(req) = rx.recv().await {
288 let q = q.clone();
289 match req {
290 HostRequest::Command { envelope, reply } => {
291 tokio::spawn(async move {
292 let _ = reply.send(forward(&q, envelope).await);
293 });
294 }
295 HostRequest::Screenshot { region, reply } => {
296 tokio::spawn(async move {
297 let _ = reply.send(capture(&q, region).await);
298 });
299 }
300 HostRequest::Annotate { reply, .. } => {
308 let _ = reply.send(Err(
309 "the window host draws no annotation overlay: docs walkthroughs are recorded by the headless host (brep-mcp)".to_string(),
310 ));
311 }
312 HostRequest::Stop => {}
314 }
315 }
316 });
317 let info = HostInfo {
318 backend: "window",
319 adapter,
320 platform: std::env::consts::OS,
321 width: 0.0,
322 height: 0.0,
323 ppp: 1.0,
324 };
325 let server = BrepServer::new(launch.session_root, Backend::Attached { tx, info });
326 match server.attach(true).await {
329 Ok(info) => log::info!("brep-app --mcp: attached session {} ({}x{} @ {})", info.id, info.host.width, info.host.height, info.host.ppp),
330 Err(e) => {
331 fail(format!("cannot attach the server to the app: {e}"));
332 return;
333 }
334 }
335 let listener = match launch.listener.set_nonblocking(true).and_then(|_| tokio::net::TcpListener::from_std(launch.listener)) {
336 Ok(l) => l,
337 Err(e) => {
338 fail(format!("listener: {e}"));
339 return;
340 }
341 };
342 if let Err(e) = brep_mcp_core::http::serve_http(server, listener).await {
343 fail(format!("server on {url} stopped: {e}"));
344 }
345 });
346 })
347 .map_err(|e| {
348 set_status(Status::Stopped { error: None });
349 format!("spawn the MCP server thread: {e}")
350 })?;
351 Ok(())
352}
353