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
