1use 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
24pub const DEFAULT_PORT: u16 = 8765;
26
27const REPLY_TIMEOUT: Duration = Duration::from_secs(120);
32
33pub const WINDOW_PORTS: std::ops::RangeInclusive<u16> = DEFAULT_PORT..=DEFAULT_PORT + 9;
39
40#[derive(Debug)]
44pub struct Launch {
45 pub listener: TcpListener,
46 pub session_root: PathBuf,
47 pub note: Option<String>,
50}
51
52impl Launch {
53 pub fn url(&self) -> String {
54 url(&self.listener)
55 }
56
57 pub fn instructions(&self) -> String {
60 agent_instructions(&self.url(), &self.session_root)
61 }
62}
63
64pub 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
70pub 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 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#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum Status {
105 Stopped { error: Option<String> },
108 Starting { url: String, session_root: PathBuf, note: Option<String> },
110 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
124fn 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
134pub 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
152pub 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 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 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
190pub 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
198pub fn agent_instructions(url: &str, session_root: &Path) -> String {
203 instructions_naming(url, session_root, &brep_mcp_core::http::Credential::path())
204}
205
206fn 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
234async 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_for(AppEnvelope { id, cmd: envelope.cmd, args: envelope.args }, Some(deadline), envelope.client);
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 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
261async 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 }), client: None }).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
272pub struct Embedded {
277 pub server: BrepServer,
278 pub runtime: tokio::runtime::Runtime,
279 attached: Arc<std::sync::OnceLock<Result<(), String>>>,
281 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
291pub 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
299pub 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 pub fn attached(&self) -> Option<Result<(), String>> {
324 self.attached.get().cloned()
325 }
326}
327
328pub 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 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 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 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
407pub 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 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 loop {
449 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
478pub 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