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:
1 parent
96356dc544
commit
1473893b08
6 files changed
+221
-32
No files matched your search
+64
-16
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user