MCP: a bridged server starts without the app and connects once it runs (#1298)

The bridged MCP server starts when no app is listening, says so in its instructions and in each tool error, and connects on the first call after the app starts. A batch that could not be sent says the app is not running. docs/mcp.md describes it.
This commit is contained in:
Mateusz Sych authored and GitHub committed 2026-10-15 02:13:18 -04:00
1 parent 96356dc544
commit 1473893b08
6 files changed
+221 -32

No files matched your search

+64 -16
View File
@@ -35,12 +35,18 @@ const BATCH_TIMEOUT: Duration = Duration::from_secs(3600);
/// (`solidcraft --control PORT`).
pub struct Remote {
addr: String,
port: u16,
conn: Option<(BufReader<TcpStream>, TcpStream)>,
next_id: u64,
/// The app's control token, sent with every request (docs/control-protocol.md).
token: Option<String>,
/// `--token`, when given. Otherwise each request looks the token up again, since every
/// launch of the app makes a new one (docs/control-protocol.md).
explicit_token: Option<String>,
}
/// How a tool error starts when no app listens at the bridged address. The agent reads it, and
/// `batch` uses it to tell "never sent" from "sent, reply lost".
pub(crate) const NOT_RUNNING: &str = "no SolidCraft app is listening at";
impl Remote {
/// Connect to the app at `addr`, with its control token found the usual way
/// (`SOLIDCRAFT_CONTROL_TOKEN`, else the app's token file for that port).
@@ -50,17 +56,31 @@ impl Remote {
/// [`Remote::connect`] with the token given (`--token`); `None` finds it the usual way.
pub fn connect_with_token(addr: &str, token: Option<&str>) -> std::io::Result<Self> {
let port = addr.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()).unwrap_or(0);
let token = solidcraft_engine::control_auth::client_token(port, token);
let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1, token };
let mut r = Self::new(addr, token);
r.reconnect()?;
Ok(r)
}
/// A bridge to the app at `addr` that connects when a call needs it: an MCP server started
/// before the app keeps its tools, and they reach the app once it runs.
pub fn new(addr: &str, token: Option<&str>) -> Self {
let port = addr.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()).unwrap_or(0);
let explicit_token = token.map(str::to_string);
Self { addr: addr.to_string(), port, conn: None, next_id: 1, explicit_token }
}
/// Connect now, if the app is there (`new` does not); the error says why not.
pub fn try_connect(&mut self) -> std::io::Result<()> {
self.reconnect()
}
/// Failing to reach the app at all is `NotConnected`, so [`Remote::explain`] can tell it from
/// a connection lost during a call.
fn reconnect(&mut self) -> std::io::Result<()> {
self.conn = None;
let not_connected = |e: std::io::Error| std::io::Error::new(std::io::ErrorKind::NotConnected, e);
let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, format!("cannot resolve {}", self.addr));
for sa in self.addr.to_socket_addrs()? {
for sa in self.addr.to_socket_addrs().map_err(not_connected)? {
match TcpStream::connect_timeout(&sa, Duration::from_millis(800)) {
Ok(s) => {
s.set_nodelay(true).ok();
@@ -72,7 +92,23 @@ impl Remote {
Err(e) => last = e,
}
}
Err(last)
Err(not_connected(last))
}
/// The tool error for a failed call: whether the app is not running (and how to start it) or
/// went away during the call.
fn explain(&self, e: &std::io::Error) -> String {
let (addr, port) = (&self.addr, self.port);
if e.kind() == std::io::ErrorKind::NotConnected {
format!(
"{NOT_RUNNING} {addr} ({e}): it is not running, or it quit. Start it with `solidcraft --control {port}` \
(or ask the user to); the next call connects to it"
)
} else {
format!(
"the SolidCraft app at {addr} did not answer ({e}): it may have quit or crashed during this call, after running it or not. Inspect the design before sending a change again; if the app quit, start it with `solidcraft --control {port}`"
)
}
}
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
@@ -130,19 +166,22 @@ impl Backend for Remote {
o.insert("allow_paths".into(), json!(scope.roots().iter().map(|r| r.to_string_lossy()).collect::<Vec<_>>()));
}
let mut msg = json!({"id": id, "method": method, "params": params});
if let Some(t) = &self.token {
// The token goes with every request (an app restarted mid-session has a new one).
if let Some(t) = solidcraft_engine::control_auth::client_token(self.port, self.explicit_token.as_deref()) {
msg["token"] = json!(t);
}
let line = msg.to_string();
let reply = if method == "engine.batch" {
self.send_batch(&line).map_err(|e| format!("SolidCraft at {}: {e}", self.addr))?
self.send_batch(&line)
} else {
match self.roundtrip(&line) {
Ok(r) => r,
// One retry on a fresh connection (the app may have restarted).
Err(_) => self.roundtrip(&line).map_err(|e| format!("SolidCraft at {}: {e}", self.addr))?,
}
};
// One retry on a fresh connection (the app may have restarted). When nothing listens
// any more, the app went away, maybe while running this request: say that, not
// just "not running".
self.roundtrip(&line).or_else(|lost| {
self.roundtrip(&line).map_err(|e| if e.kind() == std::io::ErrorKind::NotConnected && lost.kind() != e.kind() { lost } else { e })
})
}
.map_err(|e| self.explain(&e))?;
let v: Value = serde_json::from_str(&reply).map_err(|e| format!("bad reply from the app: {e}"))?;
if v.get("ok").and_then(Value::as_bool) == Some(true) {
Ok(v.get("result").cloned().unwrap_or(Value::Null))
@@ -154,7 +193,16 @@ impl Backend for Remote {
true
}
fn describe(&self) -> String {
format!("connected to the SolidCraft app at {} (the user watches the viewport)", self.addr)
if self.conn.is_some() {
format!("connected to the SolidCraft app at {} (the user watches the viewport)", self.addr)
} else {
// Still "connected to the SolidCraft app": the skills tell bridged from headless by it.
format!(
"connected to the SolidCraft app at {} once it runs (the user watches the viewport). It was not running \
when this server started; until it is (`solidcraft --control {}`), tool calls say so",
self.addr, self.port
)
}
}
}