Skip to main content

brep_app/
mcp.rs

1//! The embedded MCP server — `brep-app --mcp`.
2//!
3//! The window you launched is the session: the server (`brep_mcp_core`, the
4//! same one `brep-mcp serve` runs headlessly) lives on its own thread inside
5//! this process and reaches the app through the automation queue, exactly as
6//! the headless host does. Agents connect over MCP's streamable HTTP
7//! transport on a loopback port instead of launching a process, so Claude
8//! Code or Codex can drive the app a person is looking at.
9//!
10//! Sequence (main.rs): bind the listener → print how to configure the agent →
11//! open the window → once the app exists, [`start`] attaches the server to its
12//! queue and serves. The store is the user's own (this is their app, not a
13//! test host), which is what the tools' docs say.
14use 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
24/// The port `--mcp` listens on unless `--mcp-port` says otherwise.
25pub const DEFAULT_PORT: u16 = 8765;
26
27/// How long one command may wait for the window to answer. A window paints
28/// on demand (every submit requests a repaint), so a reply normally arrives
29/// within a frame or two; this bounds a hung or minimised app.
30const REPLY_TIMEOUT: Duration = Duration::from_secs(120);
31
32/// The ports the Info window's **Start MCP server** button tries, in order:
33/// the default, then the next few above it. The flag tries ONE port (the
34/// default or `--mcp-port`) and exits when it is taken, because a scripted
35/// launch wants the address it asked for; a person clicking a button wants a
36/// server, and is told which port it got.
37pub const WINDOW_PORTS: std::ops::RangeInclusive<u16> = DEFAULT_PORT..=DEFAULT_PORT + 9;
38
39/// A bound listener and where its sessions go: what [`start`] serves. Built by
40/// [`launch`] — the ONE place the sequence "bind, choose the session root,
41/// word the instructions" lives, for the flag and the button alike.
42#[derive(Debug)]
43pub struct Launch {
44    pub listener: TcpListener,
45    pub session_root: PathBuf,
46    /// Set when the first port asked for was taken and a later one answered:
47    /// the sentence that says so, shown above the instructions.
48    pub note: Option<String>,
49}
50
51impl Launch {
52    pub fn url(&self) -> String {
53        url(&self.listener)
54    }
55
56    /// The text the console prints and the Info window shows: one function,
57    /// so what a user copies from the window is what a terminal would show.
58    pub fn instructions(&self) -> String {
59        agent_instructions(&self.url(), &self.session_root)
60    }
61}
62
63/// Bind the loopback listener. Done before the window opens so a port in use
64/// is a launch error, never a window silently running without its server.
65pub 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
69/// Bind the first free port of `ports` and settle the session root: the
70/// start sequence shared by `--mcp` (one port) and the window's button (the
71/// [`WINDOW_PORTS`] range). `Ok` carries a [`Launch`] whose `note` names the
72/// fallback when one happened; `Err` is the bind error of the first port,
73/// with the count of the others tried, in the console's own words.
74pub 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                // The port actually bound, not the one asked for: `0` asks
80                // the OS for any free port, and a note must name a real one.
81                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/// Whether the server is up, for the Info window: written by [`start`] and by
99/// the server thread when it stops, read each frame the window is open.
100/// Process-global like the registry it serves: one app per process is the
101/// only configuration that exists, and `start` runs at most once in it.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum Status {
104    /// Never started, or stopped: `error` is the line the console printed when
105    /// something went wrong (`None` for a plain "not started").
106    Stopped { error: Option<String> },
107    /// Serving on `url`; `note` is [`Launch::note`].
108    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
121/// Report a failure the way the console always has — `brep-app --mcp: …` on
122/// stderr — AND record the same line for the window, so the panel and the
123/// terminal cannot word it differently.
124fn 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
131/// What the Info window's "Connect an agent" section draws, from [`status`].
132pub 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
144/// The button's path: [`launch`] over [`WINDOW_PORTS`] with the default
145/// session root, then [`start`] — the flag's sequence, at runtime. The
146/// instructions still go to the console too, for a window launched from a
147/// terminal. A failure is recorded in [`status`] (the window shows it) and
148/// returned.
149pub 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    // A refusal is not a failure: it must not touch the status (the button
155    // is disabled while the server runs, so this is belt and braces), and it
156    // must not bind a port it would only drop.
157    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        // main.rs's wording for the same failure, which also goes to stderr.
163        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
182/// Where sessions keep screenshots and call logs unless `--session-root`
183/// or `BREP_MCP_SESSION_ROOT` says otherwise (the same default as brep-mcp).
184pub 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
190/// The text printed in the terminal at launch: how to point an agent at this
191/// window. Both forms each agent accepts — its CLI and its config file.
192pub 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
214/// The app's reply in the server's shape: the same JSON, the blob beside it.
215fn 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
224/// Submit one command to the window and wait for its reply off the runtime.
225async 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
240/// A capture is the app's own `screenshot` command (egui's viewport
241/// screenshot, completed on a later frame); the PNG rides in the reply blob.
242async 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
251/// Start the server thread over the app's queue. Called from the eframe
252/// creation closure, once the app (and its queue) exists — or from the Info
253/// window's button, any time later; returns as soon as the thread is spawned.
254/// `adapter` names the GPU for the session info. At most ONCE per process: a
255/// second call while the server runs is refused with its address, so neither
256/// path can stack a second server on the first.
257pub 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    // The state registry publishes only for a host; this window has one now.
266    // Part of the start sequence, not of either caller, so an agent connected
267    // through the button sees `hit_rects` and `state_get` like one connected
268    // at launch.
269    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                            // The DOCS ANNOTATION overlay belongs to the
301                            // headless harness, which owns its egui context and
302                            // paints on top of the app's frame for the
303                            // walkthrough GIFs. A running window draws the
304                            // product, not a caption band over it, so this says
305                            // so rather than capturing an unannotated frame and
306                            // letting a silent GIF claim otherwise.
307                            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                            // The app is not the server's to stop.
313                            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                // Attach now: the first `tools/list` must already carry the
327                // app's commands (the HTTP transport may answer statelessly).
328                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