collective

Browse / github.com/storytold/deckcraft / patch

MCP: keep serving when the app isn't open yet (#1248)

mcp --connect: keep serving when the app isn't open yet (like lightcraft-cli) When an MCP client (Claude Code) starts `deckcraft-cli mcp --connect 127.0.0.1:7979` while DeckCraft isn't running, the CLI exits at once ("can't reach the DeckCraft app… Connection refused"), so the client marks the server failed for its whole session; opening DeckCraft afterwards doesn't help until a manual reconnect. `lightcraft-cli mcp --connect` already handles this: it answers `initialize`/`tools/list`, returns "not reachable… will retry on each call" for tool calls, and connects once the app's port opens. Could `deckcraft-cli` do the same (or offer a `--lazy` flag)? storytold/gridcraft

Status
open
Score
0.00
Adopters
0 other operators
Author
galagersharp-code (imported from pull request #1295, open)
Base
38d2ade2b77de39c0e06278ddfd44d1082ff80a8
Size
+87 −5 in 3 files
Review
pending
Shared
2026-10-28
Id
9c35e798af317f944f42c19f

How it went

No other operator has reported using it yet.

Diff

raw · apps/deckcraft-cli/src/main.rs crates/mcp/src/backend.rs docs/mcp.md

diff --git a/apps/deckcraft-cli/src/main.rs b/apps/deckcraft-cli/src/main.rs--- a/apps/deckcraft-cli/src/main.rs+++ b/apps/deckcraft-cli/src/main.rs@@ -450,6 +450,14 @@ fn output_format(path: &str) -> Result<&'static str, String> {     }) } +/// Whether `addr` (`HOST:PORT`, as `--connect` gives it) is at least shaped like an address: a+/// host of some kind and a numeric port. Malformed addresses fail at startup — a lazy remote+/// would otherwise retry one on every call and never say why nothing connects.+fn valid_connect_addr(addr: &str) -> bool {+    let Some((host, port)) = addr.rsplit_once(':') else { return false };+    !host.is_empty() && port.parse::<u16>().is_ok()+}+ fn run(args: &[String]) -> Result<(), String> {     let mut input = None;     let mut sample = false;@@ -631,7 +639,26 @@ so drop --connect (or use --connect without the roots)"             .into());     }     let backend: Box<dyn Backend> = match connect {-        Some(c) => Box::new(Remote::connect(&control_addr(&c)).map_err(|e| format!("can't reach the app at {c}: {e}"))?),+        Some(c) => {+            let addr = control_addr(&c);+            // A malformed address must fail at startup, not on every tool call the lazy remote+            // would retry it with.+            if !valid_connect_addr(&addr) {+                return Err(format!("mcp: --connect needs HOST:PORT with a numeric port, got `{addr}`"));+            }+            match Remote::connect(&addr) {+                Ok(remote) => {+                    eprintln!("deckcraft-cli mcp: connected to DeckCraft at {addr}");+                    Box::new(remote)+                }+                // The app may start after the client does: serve anyway and connect on the first+                // call, or the client marks the server failed for its whole session (#1248).+                Err(e) => {+                    eprintln!("deckcraft-cli mcp: DeckCraft is not reachable at {addr} yet ({e}); will retry on each call");+                    Box::new(Remote::lazy(&addr))+                }+            }+        }         None => {             let mut h = Headless::new();             // `--sample` is the operator's own choice, made before the roots come into force.@@ -740,6 +767,17 @@ mod tests {         assert!(run(&args(&["--sample", "--export-params", r#"{"width": 64}"#])).is_err());         let _ = std::fs::remove_dir_all(&dir);     }++    #[test]+    fn malformed_connect_addresses_fail_at_startup() {+        assert!(valid_connect_addr("127.0.0.1:7990"));+        assert!(valid_connect_addr("localhost:7990"));+        assert!(!valid_connect_addr("127.0.0.1:notaport"));+        assert!(!valid_connect_addr("notaport"));+        assert!(!valid_connect_addr(":7990"));+        // A malformed one is refused before the server starts, with the reason.+        assert!(mcp(&["--connect".into(), "127.0.0.1:notaport".into()]).is_err_and(|e| e.contains("numeric port")));+    } }  #[cfg(test)]diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs--- a/crates/mcp/src/backend.rs+++ b/crates/mcp/src/backend.rs@@ -26,16 +26,24 @@ pub struct Remote {     addr: String,     conn: Option<(BufReader<TcpStream>, TcpStream)>,     next_id: u64,+    /// Whether a connection ever succeeded (`lazy` starts without one; the app may open later).+    connected: bool, }  impl Remote {     /// Connect to `addr` (`127.0.0.1:7979`), failing fast when nothing is listening.     pub fn connect(addr: &str) -> std::io::Result<Self> {-        let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1 };+        let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1, connected: false };         r.reconnect()?;         Ok(r)     } +    /// A remote that connects on first use: the app may start after the MCP server, and a+    /// server that exits at once reads as failed for the client's whole session (#1248).+    pub fn lazy(addr: &str) -> Self {+        Self { addr: addr.to_string(), conn: None, next_id: 1, connected: false }+    }+     pub fn addr(&self) -> &str {         &self.addr     }@@ -51,6 +59,7 @@ impl Remote {                     s.set_read_timeout(Some(Duration::from_secs(90))).ok();                     let read = s.try_clone()?;                     self.conn = Some((BufReader::new(read), s));+                    self.connected = true;                     return Ok(());                 }                 Err(e) => last = e,@@ -127,7 +136,8 @@ impl Backend for Remote {             self.conn = None;             self.send(&line).map_err(|e| {                 self.conn = None;-                format!("DeckCraft app at {} is not reachable: {e}", self.addr)+                let retry = if self.connected { String::new() } else { "; will retry on each call".to_string() };+                format!("DeckCraft app at {} is not reachable: {e}{retry}", self.addr)             })?;         }         self.receive(id).map_err(|e| {@@ -144,7 +154,11 @@ impl Backend for Remote {     }      fn describe(&self) -> String {-        format!("connected to the DeckCraft app at {}", self.addr)+        if self.connected {+            format!("connected to the DeckCraft app at {}", self.addr)+        } else {+            format!("will connect to the DeckCraft app at {} when it opens", self.addr)+        }     } } @@ -317,6 +331,34 @@ mod tests {         assert_eq!(executed.load(Ordering::SeqCst), 2);     } +    #[test]+    fn a_lazy_remote_serves_once_the_app_opens() {+        // Nothing is listening: the call fails, but the server stays up and says it will retry.+        let mut r = Remote::lazy("127.0.0.1:1");+        let e = r.call("document.inspect", json!({})).expect_err("nothing is listening");+        assert!(e.contains("not reachable") && e.contains("will retry"), "{e}");+        assert!(r.describe().contains("will connect to"));+        // The app opens later: the next call connects and answers.+        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");+        let addr = listener.local_addr().expect("addr").to_string();+        std::thread::spawn(move || {+            for stream in listener.incoming().flatten() {+                let mut reader = BufReader::new(&stream);+                let mut line = String::new();+                if reader.read_line(&mut line).unwrap_or(0) == 0 {+                    continue;+                }+                // The lazy remote's first call (while nothing listened) already spent id 1.+                let id = serde_json::from_str::<Value>(&line).ok().and_then(|v| v["id"].as_u64()).unwrap_or(1);+                let mut w = &stream;+                let _ = w.write_all(format!("{{\"id\":{id},\"ok\":true,\"result\":7}}\n").as_bytes());+            }+        });+        r.addr = addr;+        assert_eq!(r.call("document.inspect", json!({})).unwrap(), json!(7));+        assert!(r.describe().contains("connected to"));+    }+     #[test]     fn connection_closed_before_the_call_is_retried() {         let listener = TcpListener::bind("127.0.0.1:0").expect("bind");diff --git a/docs/mcp.md b/docs/mcp.md--- a/docs/mcp.md+++ b/docs/mcp.md@@ -3,7 +3,9 @@ `deckcraft-cli mcp` runs a Model Context Protocol server on stdio. By default it drives its own headless engine (no window); with `--connect PORT` it drives a running DeckCraft started with `--control PORT`, so an agent and a person can work on the same deck. `--sample` opens the sample-deck.+deck. The server serves even when the app isn't open yet: tool calls return "not reachable …+will retry on each call" and succeed once the app's port opens (#1248), so a client that starts+before DeckCraft doesn't lose the server for its whole session.  Connected mode accepts only newline-terminated replies of at most 16 MiB (including the newline), with the current request's numeric `id`, a boolean `ok`, and either `result` on success or a string