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`](crate::mcp::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;
15use crate::automation::queue::AutomationQueue;
16use brep_mcp_core::host::{Backend, Envelope, HostInfo, HostRequest, NativePlatform, 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 submit wakes the
28/// desktop loop, which dispatches document-level commands without a frame
29/// (`desktop_loop`); frame-bound commands (input, captures, layout) answer on
30/// the next drawn frame. This bounds a hung app or a window never drawn.
31const REPLY_TIMEOUT: Duration = Duration::from_secs(120);
32
33/// The ports the Info window's **Start MCP server** button tries, in order:
34/// the default, then the next few above it. The flag tries ONE port (the
35/// default or `--mcp-port`) and exits when it is taken, because a scripted
36/// launch wants the address it asked for; a person clicking a button wants a
37/// server, and is told which port it got.
38pub const WINDOW_PORTS: std::ops::RangeInclusive<u16> = DEFAULT_PORT..=DEFAULT_PORT + 9;
39
40/// A bound listener and where its sessions go: what [`start`] serves. Built by
41/// [`launch`] — the ONE place the sequence "bind, choose the session root,
42/// word the instructions" lives, for the flag and the button alike.
43#[derive(Debug)]
44pub struct Launch {
45    pub listener: TcpListener,
46    pub session_root: PathBuf,
47    /// Set when the first port asked for was taken and a later one answered:
48    /// the sentence that says so, shown above the instructions.
49    pub note: Option<String>,
50}
51
52impl Launch {
53    pub fn url(&self) -> String {
54        url(&self.listener)
55    }
56
57    /// The text the console prints and the Info window shows: one function,
58    /// so what a user copies from the window is what a terminal would show.
59    pub fn instructions(&self) -> String {
60        agent_instructions(&self.url(), &self.session_root)
61    }
62}
63
64/// Bind the loopback listener. Done before the window opens so a port in use
65/// is a launch error, never a window silently running without its server.
66pub fn bind(port: u16) -> Result<TcpListener, String> {
67    TcpListener::bind(("127.0.0.1", port)).map_err(|e| format!("cannot listen on 127.0.0.1:{port}: {e}"))
68}
69
70/// Bind the first free port of `ports` and settle the session root: the
71/// start sequence shared by `--mcp` (one port) and the window's button (the
72/// [`WINDOW_PORTS`] range). `Ok` carries a [`Launch`] whose `note` names the
73/// fallback when one happened; `Err` is the bind error of the first port,
74/// with the count of the others tried, in the console's own words.
75pub fn launch(ports: impl IntoIterator<Item = u16>, session_root: Option<PathBuf>) -> Result<Launch, String> {
76    let mut failures: Vec<(u16, String)> = Vec::new();
77    for port in ports {
78        match bind(port) {
79            Ok(listener) => {
80                // The port actually bound, not the one asked for: `0` asks
81                // the OS for any free port, and a note must name a real one.
82                let bound = listener.local_addr().map(|a| a.port()).unwrap_or(port);
83                let note = failures
84                    .first()
85                    .map(|(first, _)| format!("port {first} was in use; listening on {bound} instead"));
86                let session_root = session_root.unwrap_or_else(default_session_root);
87                return Ok(Launch { listener, session_root, note });
88            }
89            Err(e) => failures.push((port, e)),
90        }
91    }
92    match failures.as_slice() {
93        [] => Err("no port to listen on".to_string()),
94        [(_, only)] => Err(only.clone()),
95        [(_, first), .., (last, _)] => Err(format!("{first} (and every port up to {last} is in use too)")),
96    }
97}
98
99/// Whether the server is up, for the Info window: written by [`start`] and by
100/// the server thread when it stops, read each frame the window is open.
101/// Process-global like the registry it serves: one app per process is the
102/// only configuration that exists, and `start` runs at most once in it.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum Status {
105    /// Never started, or stopped: `error` is the line the console printed when
106    /// something went wrong (`None` for a plain "not started").
107    Stopped { error: Option<String> },
108    /// Port reserved; application attachment has not completed yet.
109    Starting { url: String, session_root: PathBuf, note: Option<String> },
110    /// Serving on `url`; `note` is [`Launch::note`].
111    Running { url: String, session_root: PathBuf, note: Option<String> },
112}
113
114static STATUS: Mutex<Status> = Mutex::new(Status::Stopped { error: None });
115
116pub fn status() -> Status {
117    STATUS.lock().unwrap_or_else(|p| p.into_inner()).clone()
118}
119
120fn set_status(status: Status) {
121    *STATUS.lock().unwrap_or_else(|p| p.into_inner()) = status;
122}
123
124/// Report a failure the way the console always has — `brep-app --mcp: …` on
125/// stderr — AND record the same line for the window, so the panel and the
126/// terminal cannot word it differently.
127fn fail(text: impl std::fmt::Display) -> String {
128    let line = format!("brep-app --mcp: {text}");
129    eprintln!("{line}");
130    set_status(Status::Stopped { error: Some(line.clone()) });
131    line
132}
133
134/// What the Info window's "Connect an agent" section draws, from [`status`].
135pub fn info_view() -> crate::panels::info::McpView {
136    use crate::panels::info::McpView;
137    match status() {
138        Status::Stopped { error } => McpView::Stopped { error },
139        Status::Starting { url, session_root, note } => McpView::Running {
140            instructions: agent_instructions(&url, &session_root),
141            url,
142            note: Some(format!("Application host not ready; waiting for command dispatch. {}", note.unwrap_or_default())),
143        },
144        Status::Running { url, session_root, note } => McpView::Running {
145            instructions: agent_instructions(&url, &session_root),
146            url,
147            note,
148        },
149    }
150}
151
152/// The button's path: [`launch`] over [`WINDOW_PORTS`] with the default
153/// session root, then [`start`] — the flag's sequence, at runtime. The
154/// instructions still go to the console too, for a window launched from a
155/// terminal. A failure is recorded in [`status`] (the window shows it) and
156/// returned.
157pub fn start_from_window(queue: Arc<AutomationQueue>, adapter: String) -> Result<(), String> {
158    start_over(queue, adapter, WINDOW_PORTS)
159}
160
161fn start_over(queue: Arc<AutomationQueue>, adapter: String, ports: impl IntoIterator<Item = u16>) -> Result<(), String> {
162    // A refusal is not a failure: it must not touch the status (the button
163    // is disabled while the server runs, so this is belt and braces), and it
164    // must not bind a port it would only drop.
165    if let Status::Running { url, .. } | Status::Starting { url, .. } = status() {
166        return Err(format!("the MCP server is already running on {url}"));
167    }
168    let launch = match launch(ports, None) {
169        Ok(launch) => launch,
170        // main.rs's wording for the same failure, which also goes to stderr.
171        Err(e) => {
172            let line = format!("brep-app: {e}");
173            eprintln!("{line}");
174            set_status(Status::Stopped { error: Some(line.clone()) });
175            return Err(line);
176        }
177    };
178    if let Some(note) = &launch.note {
179        println!("brep-app --mcp: {note}");
180    }
181    println!("{}", launch.instructions());
182    start(queue, adapter, launch).map_err(|e| fail(e))
183}
184
185pub fn url(listener: &TcpListener) -> String {
186    let port = listener.local_addr().map(|a| a.port()).unwrap_or(DEFAULT_PORT);
187    brep_mcp_core::http::url(port)
188}
189
190/// Where sessions keep screenshots and call logs unless `--session-root`
191/// or `BREP_MCP_SESSION_ROOT` says otherwise (the same default as brep-mcp).
192pub fn default_session_root() -> PathBuf {
193    std::env::var_os("BREP_MCP_SESSION_ROOT")
194        .map(PathBuf::from)
195        .unwrap_or_else(|| std::env::temp_dir().join("brep-mcp"))
196}
197
198/// The text printed in the terminal at launch: how to point an agent at this
199/// window. Both forms each agent accepts — its CLI and its config file —
200/// each sending the server's token (D3), which stays in its file: the text
201/// names the file, never the secret.
202pub fn agent_instructions(url: &str, session_root: &Path) -> String {
203    instructions_naming(url, session_root, &brep_mcp_core::http::Credential::path())
204}
205
206/// [`agent_instructions`] naming this token file.
207fn instructions_naming(url: &str, session_root: &Path, token: &Path) -> String {
208    format!(
209        "BREP MCP endpoint {url} (serving after application host readiness)\n\
210         Headless automation: use brep-mcp serve --http PORT with its own free port.\n\
211         HTTP uses bearer authentication, not OAuth or Dynamic Client Registration.\n\
212         This window is the session: an agent's tools act on the documents you see here.\n\
213         Every request needs the server's token, `Authorization: Bearer <token>`; the token is in\n\
214         \x20 {token} (readable by you only).\n\
215         \n\
216         Claude Code:\n\
217         \x20 In a project's .mcp.json, with BREP_MCP_TOKEN loaded privately from the token file:\n\
218         \x20   {{ \"mcpServers\": {{ \"brep\": {{ \"type\": \"http\", \"url\": \"{url}\", \"headers\": {{ \"Authorization\": \"Bearer ${{BREP_MCP_TOKEN}}\" }} }} }} }}\n\
219         \n\
220         Codex, with BREP_MCP_TOKEN loaded privately from the token file:\n\
221         \x20 codex mcp add brep --url {url} --bearer-token-env-var BREP_MCP_TOKEN\n\
222         \x20 or in ~/.codex/config.toml:\n\
223         \x20   [mcp_servers.brep]\n\
224         \x20   url = \"{url}\"\n\
225         \x20   bearer_token_env_var = \"BREP_MCP_TOKEN\"\n\
226         \n\
227         Then ask the agent to use run_script; screenshots and call logs land under\n\
228         \x20 {root}\n",
229        token = token.display(),
230        root = session_root.display(),
231    )
232}
233
234/// Submit one command to the window and wait for its reply off the runtime.
235async fn forward(queue: &Arc<AutomationQueue>, envelope: Envelope) -> Reply {
236    let id = envelope.id;
237    let deadline = std::time::Instant::now() + REPLY_TIMEOUT;
238    let rx = queue.submit_before(AppEnvelope { id, cmd: envelope.cmd, args: envelope.args }, Some(deadline));
239    match tokio::task::spawn_blocking(move || (rx.recv_timeout(REPLY_TIMEOUT), rx)).await {
240        Ok((Ok(reply), _)) => reply.into_host(),
241        Ok((Err(std::sync::mpsc::RecvTimeoutError::Timeout), rx)) => {
242            // Say which: a command still queued is withdrawn and never runs;
243            // one a handler accepted may have had (or still have) effects.
244            // A withdrawal answers on `rx`, as does work that finished (or
245            // expired in the queue) in the meantime: that reply is the truth.
246            let _ = queue.withdraw(id);
247            match rx.try_recv() {
248                Ok(reply) => reply.into_host(),
249                Err(_) => Reply::err(
250                    id,
251                    queue.frame(),
252                    format!("host_deadline_exceeded: no reply within {} s; the app accepted the command and it may have had effects; host frame {}; reconcile before retrying", REPLY_TIMEOUT.as_secs(), queue.frame()),
253                ),
254            }
255        }
256        Ok((Err(std::sync::mpsc::RecvTimeoutError::Disconnected), _)) => Reply::err(id, queue.frame(), "the app dropped the command"),
257        Err(e) => Reply::err(id, queue.frame(), e.to_string()),
258    }
259}
260
261/// A capture is the app's own `screenshot` command (egui's viewport
262/// screenshot, completed on a later frame); the PNG rides in the reply blob.
263async fn capture(queue: &Arc<AutomationQueue>, region: Value) -> Result<(Value, Vec<u8>), String> {
264    let reply = forward(queue, Envelope { id: 0, cmd: "screenshot".into(), args: json!({ "region": region }) }).await;
265    if !reply.ok {
266        return Err(reply.error.unwrap_or_else(|| "screenshot failed".into()));
267    }
268    let png = reply.blob.ok_or("the screenshot reply carried no image")?;
269    Ok((reply.result.unwrap_or(Value::Null), png))
270}
271
272/// The app's one server, attached to its queue: created by the first of
273/// [`start`] (which adds the HTTP listener) and the JavaScript editor's Run
274/// (which needs none), and shared by both. It and its runtime live for the
275/// process.
276pub struct Embedded {
277    pub server: BrepServer,
278    pub runtime: tokio::runtime::Runtime,
279    /// The attach's outcome once it has finished.
280    attached: Arc<std::sync::OnceLock<Result<(), String>>>,
281    /// Signalled once `attached` is set.
282    attached_signal: Arc<tokio::sync::Notify>,
283}
284
285static SESSION_ROOT: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
286
287static EMBEDDED: std::sync::OnceLock<Result<Embedded, String>> = std::sync::OnceLock::new();
288
289static STATE_CONFIGURATION: std::sync::OnceLock<Result<brep_mcp_core::launch_state::Resolved, String>> = std::sync::OnceLock::new();
290
291/// Freeze the desktop policy before either the editor or HTTP starts the singleton.
292/// Failed resolution is sticky too: never substitute an ephemeral identity.
293pub fn configure_state(policy: &brep_mcp_core::launch_state::Policy) -> Result<(), String> {
294    let resolved = brep_mcp_core::launch_state::resolve(brep_mcp_core::launch_state::Host::Desktop, policy);
295    STATE_CONFIGURATION.set(resolved.clone()).map_err(|_| "MCP state configuration is already fixed for this launch".to_string())?;
296    resolved.map(|_| ()).map_err(fail)
297}
298
299/// Read-only UI snapshot; looking at settings must not initialize the server.
300pub fn state_configuration() -> Option<&'static Result<brep_mcp_core::launch_state::Resolved, String>> {
301    STATE_CONFIGURATION.get()
302}
303
304pub fn state_settings_path() -> Result<PathBuf, String> {
305    brep_mcp_core::launch_state::config_dir().map(|p| p.join("mcp-state.json"))
306        .ok_or_else(|| "cannot locate the machine-local MCP settings directory".into())
307}
308
309pub fn save_desktop_policy(policy: brep_mcp_core::launch_state::Policy) -> Result<(), String> {
310    save_desktop_policy_at(&state_settings_path()?, policy)
311}
312
313fn save_desktop_policy_at(path: &Path, policy: brep_mcp_core::launch_state::Policy) -> Result<(), String> {
314    policy.validate()?;
315    let mut settings = brep_mcp_core::launch_state::Settings::load(path)?;
316    settings.desktop = Some(policy);
317    settings.save(path)
318}
319
320
321impl Embedded {
322    /// `None` while the attach is in flight; then whether it succeeded.
323    pub fn attached(&self) -> Option<Result<(), String>> {
324        self.attached.get().cloned()
325    }
326}
327
328/// The app's server, created on the first call (`record`: whether its
329/// session keeps a call log; the first caller decides). Returns at once: the
330/// attach completes on the server's runtime, because it is answered by the
331/// app's frame, which may be the caller.
332pub fn embedded(queue: &Arc<AutomationQueue>, adapter: &str, record: bool) -> Result<&'static Embedded, String> {
333    EMBEDDED
334        .get_or_init(|| {
335            let policy = STATE_CONFIGURATION.get_or_init(|| {
336                brep_mcp_core::launch_state::resolve(brep_mcp_core::launch_state::Host::Desktop, &Default::default())
337            }).as_ref().map_err(Clone::clone)?;
338            policy.prepare_named_parent().map_err(fail)?;
339            // The state registry publishes only for a host; this window has
340            // one now (`hit_rects`, `state_get` read it).
341            crate::automation::registry::set_enabled(true);
342            let runtime = tokio::runtime::Builder::new_multi_thread()
343                .worker_threads(2)
344                .thread_name("brep-mcp-worker")
345                .enable_all()
346                .build()
347                .map_err(|e| format!("tokio runtime: {e}"))?;
348            let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<HostRequest>();
349            let q = queue.clone();
350            runtime.spawn(async move {
351                while let Some(req) = rx.recv().await {
352                    let q = q.clone();
353                    match req {
354                        HostRequest::Command { envelope, reply } => {
355                            tokio::spawn(async move {
356                                let _ = reply.send(forward(&q, envelope).await);
357                            });
358                        }
359                        HostRequest::Screenshot { region, reply } => {
360                            tokio::spawn(async move {
361                                let _ = reply.send(capture(&q, region).await);
362                            });
363                        }
364                        // The DOCS ANNOTATION overlay belongs to the
365                        // headless harness, which owns its egui context and
366                        // paints on top of the app's frame for the
367                        // walkthrough GIFs. A running window draws the
368                        // product, not a caption band over it, so this says
369                        // so rather than capturing an unannotated frame and
370                        // letting a silent GIF claim otherwise.
371                        HostRequest::Annotate { reply, .. } => {
372                            let _ = reply.send(Err(
373                                "the window host draws no annotation overlay: docs walkthroughs are recorded by the headless host (brep-mcp)".to_string(),
374                            ));
375                        }
376                        // The app is not the server's to stop.
377                        HostRequest::Stop => {}
378                    }
379                }
380            });
381            let info = HostInfo { backend: "window", adapter: adapter.to_string(), platform: std::env::consts::OS, width: 0.0, height: 0.0, ppp: 1.0 };
382            let server = BrepServer::new(SESSION_ROOT.get_or_init(default_session_root).clone(), Backend::Attached { tx, info, platform: Arc::new(NativePlatform) });
383            let server = match &policy.state_dir {
384                Some(path) => server.with_state_dir(path).map_err(fail)?,
385                None => server,
386            };
387            let attached = Arc::new(std::sync::OnceLock::new());
388            let attached_signal = Arc::new(tokio::sync::Notify::new());
389            let (s, done, signal) = (server.clone(), attached.clone(), attached_signal.clone());
390            runtime.spawn(async move {
391                let outcome = match s.attach(record).await {
392                    Ok(info) => {
393                        log::info!("brep-app: attached session {} ({}x{} @ {})", info.id, info.host.width, info.host.height, info.host.ppp);
394                        Ok(())
395                    }
396                    Err(e) => Err(format!("cannot attach the server to the app: {e}")),
397                };
398                let _ = done.set(outcome);
399                signal.notify_waiters();
400            });
401            Ok(Embedded { server, runtime, attached, attached_signal })
402        })
403        .as_ref()
404        .map_err(Clone::clone)
405}
406
407/// Serve the app's server over a loopback listener. Called from the eframe
408/// creation closure, once the app (and its queue) exists — or from the Info
409/// window's button, any time later; returns as soon as the listener is
410/// handed to the server. `adapter` names the GPU for the session info. At
411/// most ONCE per process: a second call while the server runs is refused
412/// with its address, so neither path can stack a second listener on the
413/// first. The JavaScript editor may have created the server already; the
414/// listener then serves that one.
415pub fn start(queue: Arc<AutomationQueue>, adapter: String, launch: Launch) -> Result<(), String> {
416    let root = SESSION_ROOT.get_or_init(|| launch.session_root.clone());
417    if root != &launch.session_root {
418        return Err(format!("the embedded session already uses {}; cannot relocate live artifacts to {}", root.display(), launch.session_root.display()));
419    }
420    {
421        let mut status = STATUS.lock().unwrap_or_else(|p| p.into_inner());
422        if let Status::Running { url, .. } | Status::Starting { url, .. } = &*status {
423            return Err(format!("the MCP server is already running on {url}"));
424        }
425        *status = Status::Starting { url: launch.url(), session_root: launch.session_root.clone(), note: launch.note.clone() };
426    }
427    // The loopback credential (D3): read or minted before anything listens.
428    let credential = brep_mcp_core::http::Credential::load_or_create().map_err(|e| {
429        set_status(Status::Stopped { error: None });
430        format!("the MCP token: {e}")
431    })?;
432    let embedded = embedded(&queue, &adapter, false).map_err(|e| {
433        set_status(Status::Stopped { error: None });
434        e
435    })?;
436    launch.listener.set_nonblocking(true).map_err(|e| {
437        set_status(Status::Stopped { error: None });
438        format!("listener: {e}")
439    })?;
440    let url = launch.url();
441    let (server, attached, signal) = (embedded.server.clone(), embedded.attached.clone(), embedded.attached_signal.clone());
442    let ready_status = Status::Running { url: url.clone(), session_root: launch.session_root.clone(), note: launch.note.clone() };
443    let std_listener = launch.listener;
444    embedded.runtime.spawn(async move {
445        // The first `tools/list` must already carry the app's commands (the
446        // HTTP transport may answer statelessly): serve once attached. The
447        // attach is itself an app command, so "Running" means dispatch works.
448        loop {
449            // Registered before the check, so a set in between still wakes it.
450            let notified = signal.notified();
451            let mut notified = std::pin::pin!(notified);
452            notified.as_mut().enable();
453            match attached.get() {
454                Some(Ok(())) => break,
455                Some(Err(e)) => {
456                    fail(e);
457                    return;
458                }
459                None => notified.await,
460            }
461        }
462        let listener = match tokio::net::TcpListener::from_std(std_listener) {
463            Ok(l) => l,
464            Err(e) => {
465                fail(format!("listener: {e}"));
466                return;
467            }
468        };
469        set_status(ready_status);
470        eprintln!("BREP MCP application host ready; serving on {url}");
471        if let Err(e) = brep_mcp_core::http::serve_http(server, listener, credential).await {
472            fail(format!("server on {url} stopped: {e}"));
473        }
474    });
475    Ok(())
476}
477
478/// The JavaScript editor's runner: the app's server's code mode, the same
479/// realm, limits and `brep.*` functions an agent's `run_script` gets.
480pub struct EditorHost {
481    queue: Arc<AutomationQueue>,
482    adapter: String,
483}
484
485impl EditorHost {
486    pub fn new(queue: Arc<AutomationQueue>, adapter: String) -> Self {
487        EditorHost { queue, adapter }
488    }
489
490    fn service(&self) -> Result<(&'static Embedded, Arc<brep_mcp_core::cad::CadService>), String> {
491        let e = embedded(&self.queue, &self.adapter, false)?;
492        Ok((e, e.server.state.cad.clone()))
493    }
494}
495
496impl crate::javascript::ScriptHost for EditorHost {
497    fn ready(&self) -> Result<bool, String> {
498        let (e, _) = self.service()?;
499        e.attached().transpose().map(|r| r.is_some())
500    }
501    fn run(&self, source: &str) -> Result<String, String> {
502        let (e, cad) = self.service()?;
503        let _enter = e.runtime.enter();
504        cad.run_local(source, "javascript")
505    }
506    fn result(&self, id: &str) -> Result<Option<Value>, String> {
507        self.service()?.1.result_local(id)
508    }
509    fn halt(&self, id: &str) -> Result<(), String> {
510        let (e, cad) = self.service()?;
511        let _enter = e.runtime.enter();
512        cad.halt_local(id)
513    }
514}
515