Skip to main content

brep_mcp_core/
host.rs

1//! The host protocol (spec §3.2). A host owns an app instance and answers
2//! commands; the server talks to it through [`HostHandle`] — a tokio channel
3//! in, a oneshot per reply out — so async tool handlers never block.
4//!
5//! Two kinds of host exist, and [`Backend`] is how a server is told which one
6//! it has: a *spawning* backend builds an app per session (BREP_mcp's headless
7//! harness on its own thread), an *attached* backend is a running app the
8//! server lives inside (`brep-app --mcp`), which every session shares and no
9//! session may stop.
10//!
11//! The wire types mirror `brep_app::automation::command::{Envelope, Reply}`
12//! field for field. They are repeated here rather than imported because this
13//! crate must not depend on the app (the app depends on it); a host converts
14//! with [`Reply::from_app`], which reads the app's own serialisation.
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17use std::path::PathBuf;
18use std::sync::Arc;
19use tokio::sync::{mpsc, oneshot};
20
21/// One command on its way to the app.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct Envelope {
24    pub id: u64,
25    pub cmd: String,
26    #[serde(default)]
27    pub args: Value,
28}
29
30/// The app's answer. `notices` are the app's `Notice` records as JSON
31/// (`{kind, frame, text}`); `blob` is the binary payload the app carries
32/// beside the JSON (a PNG), never serialised.
33#[derive(Debug, Clone, Default, Serialize, Deserialize)]
34pub struct Reply {
35    pub id: u64,
36    pub frame: u64,
37    pub ok: bool,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub result: Option<Value>,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub error: Option<String>,
42    #[serde(default, skip_serializing_if = "Vec::is_empty")]
43    pub notices: Vec<Value>,
44    #[serde(skip)]
45    pub blob: Option<Vec<u8>>,
46}
47
48impl Reply {
49    pub fn err(id: u64, frame: u64, error: impl Into<String>) -> Self {
50        Self { id, frame, ok: false, result: None, error: Some(error.into()), notices: Vec::new(), blob: None }
51    }
52
53    /// From the app's serialised reply (`serde_json::to_value(&reply)`) plus
54    /// the blob it carried beside it.
55    pub fn from_app(value: Value, blob: Option<Vec<u8>>) -> Result<Self, String> {
56        let mut reply: Reply = serde_json::from_value(value).map_err(|e| format!("app reply: {e}"))?;
57        reply.blob = blob;
58        Ok(reply)
59    }
60}
61
62/// What the server asks a host to do.
63pub enum HostRequest {
64    /// Run one command through the app's automation queue.
65    Command { envelope: Envelope, reply: oneshot::Sender<Reply> },
66    /// Capture the frame (headless renders directly; the window host turns
67    /// this into the app's `screenshot` command).
68    Screenshot { region: Value, reply: oneshot::Sender<Result<(Value, Vec<u8>), String>> },
69    Stop,
70}
71
72#[derive(Debug, Clone, serde::Serialize)]
73pub struct HostInfo {
74    pub backend: &'static str,
75    pub adapter: String,
76    pub platform: &'static str,
77    pub width: f32,
78    pub height: f32,
79    pub ppp: f32,
80}
81
82/// What a spawning backend builds an app from (`session_start`'s arguments).
83#[derive(Debug, Clone)]
84pub struct HostConfig {
85    pub width: f32,
86    pub height: f32,
87    pub ppp: f32,
88    pub seed: bool,
89    /// The session's private store directory (spec §8).
90    pub store_dir: PathBuf,
91}
92
93pub type SpawnFn = Arc<dyn Fn(HostConfig) -> Result<HostHandle, String> + Send + Sync>;
94
95/// Where a server's sessions run.
96#[derive(Clone)]
97pub enum Backend {
98    /// The server builds an app per session through `spawn`, on the host's
99    /// own thread, and stops it at `session_stop`.
100    Spawn { name: &'static str, spawn: SpawnFn },
101    /// The server lives inside a running app. Every session attaches to it;
102    /// `session_stop` detaches and the app stays open.
103    Attached { tx: mpsc::UnboundedSender<HostRequest>, info: HostInfo },
104}
105
106impl Backend {
107    pub fn name(&self) -> &'static str {
108        match self {
109            Backend::Spawn { name, .. } => name,
110            Backend::Attached { info, .. } => info.backend,
111        }
112    }
113
114    pub fn is_attached(&self) -> bool {
115        matches!(self, Backend::Attached { .. })
116    }
117
118    /// A handle on the attached app (None for a spawning backend).
119    pub fn attached_handle(&self) -> Option<HostHandle> {
120        match self {
121            Backend::Attached { tx, info } => Some(HostHandle::attached(tx.clone(), info.clone())),
122            Backend::Spawn { .. } => None,
123        }
124    }
125}
126
127pub struct HostHandle {
128    pub tx: mpsc::UnboundedSender<HostRequest>,
129    pub info: HostInfo,
130    join: Option<std::thread::JoinHandle<()>>,
131    /// Owned hosts are stopped with the session; an attached host is someone
132    /// else's app and `stop` sends it nothing.
133    owned: bool,
134    next_id: std::sync::atomic::AtomicU64,
135}
136
137impl HostHandle {
138    /// A host this handle owns: `stop` ends it and joins its thread.
139    pub fn new(tx: mpsc::UnboundedSender<HostRequest>, info: HostInfo, join: std::thread::JoinHandle<()>) -> Self {
140        Self { tx, info, join: Some(join), owned: true, next_id: std::sync::atomic::AtomicU64::new(1) }
141    }
142
143    /// A handle on a running app the server does not own.
144    pub fn attached(tx: mpsc::UnboundedSender<HostRequest>, info: HostInfo) -> Self {
145        Self { tx, info, join: None, owned: false, next_id: std::sync::atomic::AtomicU64::new(1) }
146    }
147
148    pub fn is_owned(&self) -> bool {
149        self.owned
150    }
151
152    /// Run a command and await its reply.
153    pub async fn call(&self, cmd: &str, args: Value) -> Result<Reply, String> {
154        let id = self.next_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
155        let (tx, rx) = oneshot::channel();
156        self.tx
157            .send(HostRequest::Command { envelope: Envelope { id, cmd: cmd.to_string(), args }, reply: tx })
158            .map_err(|_| "the app host has stopped".to_string())?;
159        rx.await.map_err(|_| "the app host dropped the reply (did it panic?)".to_string())
160    }
161
162    /// Run a command; an `ok: false` reply becomes `Err(error)`.
163    pub async fn call_ok(&self, cmd: &str, args: Value) -> Result<Reply, String> {
164        let r = self.call(cmd, args).await?;
165        if r.ok {
166            Ok(r)
167        } else {
168            Err(r.error.unwrap_or_else(|| "command failed".into()))
169        }
170    }
171
172    pub async fn screenshot(&self, region: Value) -> Result<(Value, Vec<u8>), String> {
173        let (tx, rx) = oneshot::channel();
174        self.tx
175            .send(HostRequest::Screenshot { region, reply: tx })
176            .map_err(|_| "the app host has stopped".to_string())?;
177        rx.await.map_err(|_| "the app host dropped the screenshot reply".to_string())?
178    }
179
180    /// Stop an owned host and wait for its thread; a no-op for an attached one.
181    pub fn stop(mut self) {
182        if !self.owned {
183            return;
184        }
185        let _ = self.tx.send(HostRequest::Stop);
186        if let Some(j) = self.join.take() {
187            let _ = j.join();
188        }
189    }
190}
191
192// BREP private tests: d8991b415c86cbef