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;
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/// What main.rs hands to [`start`] once the app exists.
33pub struct Launch {
34    pub listener: TcpListener,
35    pub session_root: PathBuf,
36}
37
38/// Bind the loopback listener. Done before the window opens so a port in use
39/// is a launch error, never a window silently running without its server.
40pub fn bind(port: u16) -> Result<TcpListener, String> {
41    TcpListener::bind(("127.0.0.1", port)).map_err(|e| format!("cannot listen on 127.0.0.1:{port}: {e}"))
42}
43
44pub fn url(listener: &TcpListener) -> String {
45    let port = listener.local_addr().map(|a| a.port()).unwrap_or(DEFAULT_PORT);
46    brep_mcp_core::http::url(port)
47}
48
49/// Where sessions keep screenshots and call logs unless `--session-root`
50/// or `BREP_MCP_SESSION_ROOT` says otherwise (the same default as brep-mcp).
51pub fn default_session_root() -> PathBuf {
52    std::env::var_os("BREP_MCP_SESSION_ROOT")
53        .map(PathBuf::from)
54        .unwrap_or_else(|| std::env::temp_dir().join("brep-mcp"))
55}
56
57/// The text printed in the terminal at launch: how to point an agent at this
58/// window. Both forms each agent accepts — its CLI and its config file.
59pub fn agent_instructions(url: &str, session_root: &Path) -> String {
60    format!(
61        "BREP MCP server listening on {url}\n\
62         This window is the session: an agent's tools act on the documents you see here.\n\
63         \n\
64         Claude Code:\n\
65         \x20 claude mcp add --transport http brep {url}\n\
66         \x20 or in a project's .mcp.json:\n\
67         \x20   {{ \"mcpServers\": {{ \"brep\": {{ \"type\": \"http\", \"url\": \"{url}\" }} }} }}\n\
68         \n\
69         Codex:\n\
70         \x20 codex mcp add brep --url {url}\n\
71         \x20 or in ~/.codex/config.toml:\n\
72         \x20   [mcp_servers.brep]\n\
73         \x20   url = \"{url}\"\n\
74         \n\
75         Then ask the agent to call session_start; screenshots and call logs land under\n\
76         \x20 {root}\n",
77        root = session_root.display(),
78    )
79}
80
81/// The app's reply in the server's shape: the same JSON, the blob beside it.
82fn convert(mut r: AppReply) -> Reply {
83    let blob = r.blob.take();
84    let (id, frame) = (r.id, r.frame);
85    match serde_json::to_value(&r).map_err(|e| e.to_string()).and_then(|v| Reply::from_app(v, blob)) {
86        Ok(reply) => reply,
87        Err(e) => Reply::err(id, frame, e),
88    }
89}
90
91/// Submit one command to the window and wait for its reply off the runtime.
92async fn forward(queue: &Arc<AutomationQueue>, envelope: Envelope) -> Reply {
93    let id = envelope.id;
94    let rx = queue.submit(AppEnvelope { id, cmd: envelope.cmd, args: envelope.args });
95    match tokio::task::spawn_blocking(move || rx.recv_timeout(REPLY_TIMEOUT)).await {
96        Ok(Ok(reply)) => convert(reply),
97        Ok(Err(std::sync::mpsc::RecvTimeoutError::Timeout)) => Reply::err(
98            id,
99            queue.frame(),
100            format!("no reply from the app within {} s (is the window responding?)", REPLY_TIMEOUT.as_secs()),
101        ),
102        Ok(Err(std::sync::mpsc::RecvTimeoutError::Disconnected)) => Reply::err(id, queue.frame(), "the app dropped the command"),
103        Err(e) => Reply::err(id, queue.frame(), e.to_string()),
104    }
105}
106
107/// A capture is the app's own `screenshot` command (egui's viewport
108/// screenshot, completed on a later frame); the PNG rides in the reply blob.
109async fn capture(queue: &Arc<AutomationQueue>, region: Value) -> Result<(Value, Vec<u8>), String> {
110    let reply = forward(queue, Envelope { id: 0, cmd: "screenshot".into(), args: json!({ "region": region }) }).await;
111    if !reply.ok {
112        return Err(reply.error.unwrap_or_else(|| "screenshot failed".into()));
113    }
114    let png = reply.blob.ok_or("the screenshot reply carried no image")?;
115    Ok((reply.result.unwrap_or(Value::Null), png))
116}
117
118/// Start the server thread over the app's queue. Called from the eframe
119/// creation closure, once the app (and its queue) exists; returns as soon as
120/// the thread is spawned. `adapter` names the GPU for the session info.
121pub fn start(queue: Arc<AutomationQueue>, adapter: String, launch: Launch) -> Result<(), String> {
122    let rt = tokio::runtime::Builder::new_multi_thread()
123        .worker_threads(2)
124        .thread_name("brep-mcp-worker")
125        .enable_all()
126        .build()
127        .map_err(|e| format!("tokio runtime: {e}"))?;
128    let url = url(&launch.listener);
129    std::thread::Builder::new()
130        .name("brep-mcp".into())
131        .spawn(move || {
132            rt.block_on(async move {
133                let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<HostRequest>();
134                let q = queue.clone();
135                tokio::spawn(async move {
136                    while let Some(req) = rx.recv().await {
137                        let q = q.clone();
138                        match req {
139                            HostRequest::Command { envelope, reply } => {
140                                tokio::spawn(async move {
141                                    let _ = reply.send(forward(&q, envelope).await);
142                                });
143                            }
144                            HostRequest::Screenshot { region, reply } => {
145                                tokio::spawn(async move {
146                                    let _ = reply.send(capture(&q, region).await);
147                                });
148                            }
149                            // The app is not the server's to stop.
150                            HostRequest::Stop => {}
151                        }
152                    }
153                });
154                let info = HostInfo {
155                    backend: "window",
156                    adapter,
157                    platform: std::env::consts::OS,
158                    width: 0.0,
159                    height: 0.0,
160                    ppp: 1.0,
161                };
162                let server = BrepServer::new(launch.session_root, Backend::Attached { tx, info });
163                // Attach now: the first `tools/list` must already carry the
164                // app's commands (the HTTP transport may answer statelessly).
165                match server.attach(true).await {
166                    Ok(info) => log::info!("brep-app --mcp: attached session {} ({}x{} @ {})", info.id, info.host.width, info.host.height, info.host.ppp),
167                    Err(e) => {
168                        eprintln!("brep-app --mcp: cannot attach the server to the app: {e}");
169                        return;
170                    }
171                }
172                let listener = match launch.listener.set_nonblocking(true).and_then(|_| tokio::net::TcpListener::from_std(launch.listener)) {
173                    Ok(l) => l,
174                    Err(e) => {
175                        eprintln!("brep-app --mcp: listener: {e}");
176                        return;
177                    }
178                };
179                if let Err(e) = brep_mcp_core::http::serve_http(server, listener).await {
180                    eprintln!("brep-app --mcp: server on {url} stopped: {e}");
181                }
182            });
183        })
184        .map_err(|e| format!("spawn the MCP server thread: {e}"))?;
185    Ok(())
186}
187
188// BREP private tests: 485db7c99bc1e72d