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
@@ -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)]@@ -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");@@ -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