diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -307,6 +307,16 @@ dependencies = [
  "serde_json",
 ]
 
+[[package]]
+name = "artcraft-launcher-mcp"
+version = "0.4.1"
+dependencies = [
+ "artcraft-launcher-core",
+ "artcraft-launcher-native",
+ "serde_json",
+ "tempfile",
+]
+
 [[package]]
 name = "artcraft-launcher-native"
 version = "0.4.1"
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -222,6 +222,14 @@ mirror, never GitHub. Only builds with the `local-mirror` feature read it; relea
 New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md). Release packaging lives in
 [`packaging/`](packaging/) and [`.github/workflows/release.yml`](.github/workflows/release.yml).
 
+### Use installed apps from Codex or another MCP client
+
+The opt-in `artcraft-launcher-mcp` development gateway gives an MCP client one connection for
+discovering installed Craft Apps and using their native tools. Its first live adapters cover
+FigCraft, DesignCraft and VectorCraft; other reachable CLIs can be used in explicit headless
+mode. It is built separately and is not yet included in release installers. See
+[`docs/mcp.md`](docs/mcp.md) for setup, capabilities and validation.
+
 > [!IMPORTANT]
 > **Status:** ArtCraft Launcher is new and in early alpha. Expect rough edges, and please file
 > issues (include your OS, which app you were installing and the launcher's log file) or tell us on
diff --git a/apps/artcraft-launcher-mcp/Cargo.toml b/apps/artcraft-launcher-mcp/Cargo.toml
new file mode 100644
--- /dev/null
+++ b/apps/artcraft-launcher-mcp/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "artcraft-launcher-mcp"
+description = "ArtCraft Launcher MCP gateway: discover installed Craft Apps and connect their native tools"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+rust-version.workspace = true
+publish = false
+
+[lints]
+workspace = true
+
+[dependencies]
+artcraft-launcher-core = { workspace = true }
+artcraft-launcher-native = { workspace = true }
+serde_json = { workspace = true }
+
+[dev-dependencies]
+tempfile = "3"
diff --git a/apps/artcraft-launcher-mcp/src/discovery.rs b/apps/artcraft-launcher-mcp/src/discovery.rs
new file mode 100644
--- /dev/null
+++ b/apps/artcraft-launcher-mcp/src/discovery.rs
@@ -0,0 +1,136 @@
+//! Reuse the launcher's installed-app detection; never fetch or install software.
+
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+use artcraft_launcher_core::{CraftApp, InstallSource, Installed, SUITE};
+use artcraft_launcher_native::{detect, manifest::Manifest, paths::Paths, process};
+use serde_json::{Value, json};
+
+#[derive(Clone, Debug)]
+pub(crate) struct Program {
+    pub path: PathBuf,
+    pub args: Vec<String>,
+}
+
+impl Program {
+    pub fn command(&self) -> Command {
+        let mut command = process::command(&self.path);
+        command.args(&self.args);
+        command
+    }
+}
+
+pub(crate) struct Found {
+    pub app: &'static CraftApp,
+    pub installed: Option<Installed>,
+    pub cli: Option<Program>,
+}
+
+impl Found {
+    pub fn describe(&self) -> Value {
+        json!({
+            "app":self.app.id, "name":self.app.name, "purpose":self.app.tagline,
+            "installed":self.installed.is_some() || self.cli.is_some(),
+            "cli_available":self.cli.is_some(),
+            "version":self.installed.as_ref().and_then(|i| i.version.as_ref()).map(ToString::to_string),
+            "live_supported":live_flag(self.app.id).is_some(),
+            "headless_available":self.cli.is_some(),
+            "installation":self.installed.as_ref().map(|i| &i.location),
+        })
+    }
+
+    pub fn gui(&self) -> Result<Program, String> {
+        let installed = self.installed.as_ref().ok_or("Desktop app not detected; install it with ArtCraft Launcher")?;
+        let path = PathBuf::from(&installed.location);
+        #[cfg(target_os = "macos")]
+        if path.extension().is_some_and(|e| e == "app") {
+            return Ok(Program { path: "open".into(), args: vec!["-n".into(), "-a".into(), path.display().to_string(), "--args".into()] });
+        }
+        if path.is_file() && !matches!(installed.source, InstallSource::AppImage | InstallSource::Flatpak) {
+            return Ok(Program { path, args: Vec::new() });
+        }
+        Err("Live launching this installation format is not supported yet; start the app with --control PORT and supply port to app_connect".into())
+    }
+}
+
+/// Only adapters whose live argument conventions are implemented in this first increment.
+pub(crate) fn live_flag(app: &str) -> Option<&'static str> {
+    match app {
+        "figcraft" => Some("--attach"),
+        "designcraft" | "vectorcraft" => Some("--connect"),
+        _ => None,
+    }
+}
+
+pub(crate) fn scan(cli_dirs: &[PathBuf]) -> Vec<Found> {
+    let manifest = Paths::new().ok().map(|p| Manifest::load(&p.manifest())).unwrap_or_default();
+    let snapshot = detect::Snapshot::take();
+    SUITE
+        .iter()
+        .map(|app| {
+            let installed = detect::detect(app, &manifest, &snapshot);
+            let cli = find_cli(app, installed.as_ref(), cli_dirs);
+            Found { app, installed, cli }
+        })
+        .collect()
+}
+
+fn find_cli(app: &CraftApp, installed: Option<&Installed>, cli_dirs: &[PathBuf]) -> Option<Program> {
+    let mut dirs = cli_dirs.to_vec();
+    if let Some(installed) = installed {
+        let location = Path::new(&installed.location);
+        if let Some(parent) = location.parent() {
+            dirs.push(parent.to_path_buf());
+        }
+        if location.is_dir() {
+            dirs.push(location.join("bin"));
+            dirs.push(location.join("Contents/MacOS"));
+        }
+    }
+    if let Some(path) = std::env::var_os("PATH") {
+        dirs.extend(std::env::split_paths(&path));
+    }
+    for dir in dirs {
+        for stem in app.stems() {
+            let path = dir.join(format!("{stem}-cli{}", std::env::consts::EXE_SUFFIX));
+            if path.is_file() {
+                return Some(Program { path, args: Vec::new() });
+            }
+        }
+    }
+    if let Some(installed) = installed
+        && installed.source == InstallSource::Flatpak
+    {
+        // Match how the launcher reaches host Flatpaks from inside its own Flatpak.
+        let mut args: Vec<String> = if process::in_sandbox() { vec!["--host".into(), "flatpak".into()] } else { Vec::new() };
+        args.extend(["run".into(), format!("--command={}-cli", app.id), app.bundle_id()]);
+        return Some(Program { path: if process::in_sandbox() { "flatpak-spawn".into() } else { "flatpak".into() }, args });
+    }
+    None
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn finds_a_cli_installed_after_the_first_scan_without_a_restart() {
+        let root = tempfile::tempdir().unwrap();
+        let app = CraftApp::by_id("figcraft").unwrap();
+        let dirs = [root.path().to_path_buf()];
+        assert!(find_cli(app, None, &dirs).is_none_or(|p| !p.path.starts_with(root.path())));
+        let path = root.path().join(format!("figcraft-cli{}", std::env::consts::EXE_SUFFIX));
+        std::fs::write(&path, b"test fixture, never executed").unwrap();
+        assert_eq!(find_cli(app, None, &dirs).unwrap().path, path);
+    }
+
+    #[test]
+    fn live_coverage_is_explicit_and_pdf_never_claims_live_support() {
+        assert_eq!(live_flag("figcraft"), Some("--attach"));
+        assert_eq!(live_flag("vectorcraft"), Some("--connect"));
+        assert_eq!(live_flag("designcraft"), Some("--connect"));
+        assert_eq!(live_flag("pdfcraft"), None);
+        assert_eq!(live_flag("not-an-app"), None);
+    }
+}
diff --git a/apps/artcraft-launcher-mcp/src/gateway.rs b/apps/artcraft-launcher-mcp/src/gateway.rs
new file mode 100644
--- /dev/null
+++ b/apps/artcraft-launcher-mcp/src/gateway.rs
@@ -0,0 +1,396 @@
+//! A stable tool catalog; downstream schemas are discovered only when requested.
+
+use std::collections::BTreeMap;
+use std::net::{Ipv4Addr, SocketAddrV4, TcpListener, TcpStream};
+use std::path::PathBuf;
+use std::time::{Duration, Instant};
+
+use artcraft_launcher_native::process;
+use serde_json::{Value, json};
+
+use crate::discovery;
+use crate::transport::Client;
+
+struct Session {
+    app: String,
+    mode: String,
+    port: Option<u16>,
+    initial_document_inspection_error: Option<Value>,
+    client: Client,
+}
+
+pub(crate) struct Gateway {
+    cli_dirs: Vec<PathBuf>,
+    sessions: BTreeMap<String, Session>,
+    next_session: u64,
+    initialized: bool,
+}
+
+impl Gateway {
+    pub fn new(cli_dirs: Vec<PathBuf>) -> Self {
+        Self { cli_dirs, sessions: BTreeMap::new(), next_session: 0, initialized: false }
+    }
+
+    pub fn handle(&mut self, request: Value) -> Option<Value> {
+        let id = request.get("id").cloned().unwrap_or(Value::Null);
+        let method = request.get("method").and_then(Value::as_str);
+        let valid_id = id.is_null() || id.is_string() || id.is_i64() || id.is_u64();
+        if request.get("jsonrpc").and_then(Value::as_str) != Some("2.0") || method.is_none() || !valid_id {
+            return Some(crate::rpc_error(Value::Null, -32600, "Invalid JSON-RPC request"));
+        }
+        let method = method.unwrap_or_default();
+        // Never execute a write carried by a notification.
+        request.get("id")?;
+        let params = request.get("params").cloned().unwrap_or_else(|| json!({}));
+        if !params.is_object() {
+            return Some(crate::rpc_error(id, -32602, "params must be an object"));
+        }
+        let result = match method {
+            "initialize" => {
+                let requested = params.get("protocolVersion").and_then(Value::as_str).unwrap_or_default();
+                let version = match requested {
+                    "2024-11-05" | "2025-03-26" => requested,
+                    _ => "2025-06-18",
+                };
+                self.initialized = true;
+                Ok(json!({
+                    "protocolVersion":version, "capabilities":{"tools":{}},
+                    "serverInfo":{"name":"artcraft-launcher", "title":"ArtCraft Launcher", "version":env!("CARGO_PKG_VERSION")},
+                    "instructions":"Use apps_list to discover installed Craft Apps. Connect the named app with app_connect, then inspect app_tools for exact schemas. Use app_read for verified read-only tools and app_call for edits. Live mode is explicit; never silently substitute headless. Pin the returned session id. Inspect before edits and after an uncertain result; never blindly retry a mutation. Return previews as images and preserve native files."
+                }))
+            }
+            "ping" => Ok(json!({})),
+            _ if !self.initialized => return Some(crate::rpc_error(id, -32002, "Initialize the MCP connection first")),
+            "tools/list" => Ok(json!({"tools":tools()})),
+            "tools/call" => match self.call(&params) {
+                Ok(result) => Ok(result),
+                Err(error) => Ok(json!({"content":[{"type":"text", "text":error}], "isError":true})),
+            },
+            _ => return Some(crate::rpc_error(id, -32601, "Unknown method")),
+        };
+        match result {
+            Ok(result) => Some(json!({"jsonrpc":"2.0", "id":id, "result":result})),
+            Err(error) => Some(crate::rpc_error(id, -32603, error)),
+        }
+    }
+
+    fn call(&mut self, params: &Value) -> Result<Value, String> {
+        keys(params, &["name", "arguments", "_meta"])?;
+        let name = string(params, "name")?;
+        let arguments = params.get("arguments").cloned().unwrap_or_else(|| json!({}));
+        if !arguments.is_object() {
+            return Err("arguments must be an object".into());
+        }
+        match name {
+            "apps_list" => {
+                keys(&arguments, &[])?;
+                let apps: Vec<Value> = discovery::scan(&self.cli_dirs).iter().map(|a| a.describe()).collect();
+                text(json!({"apps":apps}))
+            }
+            "app_connect" => self.connect(&arguments),
+            "app_tools" => {
+                keys(&arguments, &["session", "filter"])?;
+                let session = self.session(&arguments)?;
+                let filter = match arguments.get("filter") {
+                    None => String::new(),
+                    Some(value) => value.as_str().ok_or("filter must be a string")?.to_lowercase(),
+                };
+                let matches: Vec<Value> = session
+                    .client
+                    .tools
+                    .iter()
+                    .filter(|tool| {
+                        ["name", "description"]
+                            .iter()
+                            .any(|key| tool.get(key).and_then(Value::as_str).is_some_and(|v| v.to_lowercase().contains(&filter)))
+                    })
+                    .cloned()
+                    .collect();
+                let total = matches.len();
+                text(json!({"tools":matches.into_iter().take(40).collect::<Vec<_>>(), "total":total, "truncated":total > 40}))
+            }
+            "app_read" | "app_call" => {
+                keys(&arguments, &["session", "tool", "arguments"])?;
+                let tool_name = string(&arguments, "tool")?.to_string();
+                let tool_arguments = arguments.get("arguments").cloned().unwrap_or_else(|| json!({}));
+                if !tool_arguments.is_object() {
+                    return Err("Downstream arguments must be an object".into());
+                }
+                let session = self.session(&arguments)?;
+                let tool = session
+                    .client
+                    .tools
+                    .iter()
+                    .find(|t| t.get("name").and_then(Value::as_str) == Some(&tool_name))
+                    .ok_or("Unknown app tool; use app_tools to discover its schema")?;
+                if name == "app_read" && tool.pointer("/annotations/readOnlyHint").and_then(Value::as_bool) != Some(true) {
+                    return Err("This tool does not declare itself read-only; use app_call with normal edit permissions".into());
+                }
+                // The native MCP implementation validates its own input schema and executes.
+                // Preserve the full CallToolResult, including images, structuredContent and isError.
+                session.client.request("tools/call", json!({"name":tool_name, "arguments":tool_arguments}))
+            }
+            _ => Err(format!("Unknown gateway tool: {name}")),
+        }
+    }
+
+    fn session(&mut self, arguments: &Value) -> Result<&mut Session, String> {
+        let id = string(arguments, "session")?;
+        self.sessions.get_mut(id).ok_or_else(|| "Unknown session; use app_connect first".into())
+    }
+
+    fn connect(&mut self, arguments: &Value) -> Result<Value, String> {
+        keys(arguments, &["app", "mode", "port"])?;
+        let app = string(arguments, "app")?;
+        let mode = arguments.get("mode").map_or(Ok("live"), |v| v.as_str().ok_or("mode must be a string"))?;
+        if !matches!(mode, "live" | "headless") {
+            return Err("mode must be live or headless".into());
+        }
+        let supplied_port = arguments
+            .get("port")
+            .map(|v| v.as_u64().and_then(|p| u16::try_from(p).ok()).filter(|p| *p != 0).ok_or("port must be an integer from 1 to 65535"))
+            .transpose()?;
+        if mode == "headless" && supplied_port.is_some() {
+            return Err("port is only valid in live mode".into());
+        }
+        let flag = discovery::live_flag(app);
+        if mode == "live" && flag.is_none() {
+            return Err(format!(
+                "Live mode for {app} is not implemented in this gateway version. Explicitly choose mode=headless for file-based editing; it will not update a desktop window."
+            ));
+        }
+        let mut target_port = supplied_port;
+        let mut replaced_session = None;
+        if let Some((id, session)) =
+            self.sessions.iter().find(|(_, s)| s.app == app && s.mode == mode && (supplied_port.is_none() || s.port == supplied_port))
+        {
+            if session.client.healthy {
+                return describe_session(id, session);
+            }
+            // Explicit reconnection must inspect the same live document after a lost reply.
+            // Never replay the failed operation or silently open a different document.
+            if mode == "live" {
+                target_port = session.port;
+            }
+            replaced_session = Some(id.clone());
+        }
+        if replaced_session.is_none() && self.sessions.len() >= 32 {
+            return Err("This gateway already has 32 sessions. Save work and restart it before opening more.".into());
+        }
+        let found = discovery::scan(&self.cli_dirs).into_iter().find(|a| a.app.id == app).ok_or("Unknown app; use an app id from apps_list")?;
+        let mut program = found.cli.clone().ok_or_else(|| {
+            format!("{app}'s CLI was not found. Install it through ArtCraft Launcher, or supply its trusted folder with --cli-dir.")
+        })?;
+        program.args.push("mcp".into());
+        let port = if mode == "live" {
+            let port = match target_port {
+                Some(port) => port,
+                None => {
+                    let listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)).map_err(|e| e.to_string())?;
+                    let port = listener.local_addr().map_err(|e| e.to_string())?.port();
+                    drop(listener);
+                    let mut gui = found.gui()?.command();
+                    gui.args(["--control", &port.to_string()]);
+                    process::spawn_detached(gui)?;
+                    wait_for_app(port).map_err(|e| format!("{e} (control port {port})"))?;
+                    port
+                }
+            };
+            program.args.extend([flag.ok_or("No live adapter")?.into(), format!("127.0.0.1:{port}")]);
+            Some(port)
+        } else {
+            None
+        };
+        let mut client = Client::start(&program).map_err(|e| format!("Could not connect {app} (control port {port:?}): {e}"))?;
+        let mut initial_document_inspection_error = None;
+        if mode == "live" {
+            let inspect = if app == "vectorcraft" { "doc_inspect" } else { "inspect_document" };
+            if !client.tools.iter().any(|t| t.get("name").and_then(Value::as_str) == Some(inspect)) {
+                return Err("The installed CLI lacks the expected live inspection tool; check its version".into());
+            }
+            let state = client.request("tools/call", json!({"name":inspect, "arguments":{}}))?;
+            let ui = if state.get("isError").and_then(Value::as_bool) == Some(true) {
+                // Some apps start at a welcome screen. Verify the desktop endpoint without
+                // creating a document, then expose the inspection error to the caller.
+                let tool = ["inspect_ui", "ui_inspect"]
+                    .into_iter()
+                    .find(|name| {
+                        client.tools.iter().any(|t| {
+                            t.get("name").and_then(Value::as_str) == Some(name)
+                                && t.pointer("/annotations/readOnlyHint").and_then(Value::as_bool) == Some(true)
+                        })
+                    })
+                    .ok_or_else(|| format!("Cannot verify the live app on port {port:?}: {state}"))?;
+                Some(client.request("tools/call", json!({"name":tool, "arguments":{}}))?)
+            } else {
+                None
+            };
+            initial_document_inspection_error = validate_live_inspection(state, ui)?;
+        }
+        self.next_session = self.next_session.checked_add(1).ok_or("Session id exhausted")?;
+        let id = format!("{app}-{}", self.next_session);
+        let session = Session { app: app.into(), mode: mode.into(), port, initial_document_inspection_error, client };
+        let result = describe_session(&id, &session)?;
+        if let Some(old_id) = replaced_session {
+            self.sessions.remove(&old_id);
+        }
+        self.sessions.insert(id, session);
+        Ok(result)
+    }
+}
+
+fn wait_for_app(port: u16) -> Result<(), String> {
+    let address = SocketAddrV4::new(Ipv4Addr::LOCALHOST, port).into();
+    let deadline = Instant::now() + Duration::from_secs(20);
+    while Instant::now() < deadline {
+        if TcpStream::connect_timeout(&address, Duration::from_millis(300)).is_ok() {
+            return Ok(());
+        }
+        std::thread::sleep(Duration::from_millis(100));
+    }
+    Err("The app did not open its control endpoint within 20 seconds. Check its window, then connect to its port explicitly.".into())
+}
+
+fn describe_session(id: &str, session: &Session) -> Result<Value, String> {
+    text(json!({"session":id, "app":session.app, "mode":session.mode, "port":session.port, "tool_count":session.client.tools.len(),
+        "initial_document_inspection_error":session.initial_document_inspection_error}))
+}
+
+fn validate_live_inspection(document: Value, ui: Option<Value>) -> Result<Option<Value>, String> {
+    if document.get("isError").and_then(Value::as_bool) != Some(true) {
+        return Ok(None);
+    }
+    if ui.as_ref().is_some_and(|result| result.get("isError").and_then(Value::as_bool) != Some(true)) {
+        return Ok(Some(document));
+    }
+    Err(format!("Neither the live document nor desktop UI could be inspected: {document}; UI: {ui:?}"))
+}
+
+fn text(value: Value) -> Result<Value, String> {
+    Ok(json!({"content":[{"type":"text", "text":serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?}]}))
+}
+
+fn string<'a>(value: &'a Value, key: &str) -> Result<&'a str, String> {
+    value.get(key).and_then(Value::as_str).ok_or_else(|| format!("{key} must be a string"))
+}
+
+fn keys(value: &Value, allowed: &[&str]) -> Result<(), String> {
+    let object = value.as_object().ok_or("Expected an object")?;
+    if let Some(key) = object.keys().find(|key| !allowed.contains(&key.as_str())) {
+        return Err(format!("Unknown argument: {key}"));
+    }
+    Ok(())
+}
+
+fn tools() -> Vec<Value> {
+    let app = json!({"type":"string", "enum":artcraft_launcher_core::SUITE.iter().map(|a|a.id).collect::<Vec<_>>()});
+    let session = json!({"type":"string", "description":"Session handle returned by app_connect"});
+    let dispatch = json!({"type":"object", "properties":{"session":session, "tool":{"type":"string"}, "arguments":{"type":"object"}}, "required":["session","tool"], "additionalProperties":false});
+    vec![
+        tool(
+            "apps_list",
+            "Discover installed Craft Apps now, including apps installed since Codex started. Does not launch apps.",
+            json!({"type":"object","properties":{},"additionalProperties":false}),
+            true,
+            false,
+        ),
+        tool(
+            "app_connect",
+            "Connect to a named app. Default live mode opens a separate controlled desktop window on demand; supplying port attaches to an operator-selected localhost endpoint. Live adapters: FigCraft, DesignCraft, VectorCraft. Explicit headless mode edits a separate in-memory file session, not a visible window.",
+            json!({"type":"object","properties":{"app":app,"mode":{"type":"string","enum":["live","headless"]},"port":{"type":"integer","minimum":1,"maximum":65535}},"required":["app"],"additionalProperties":false}),
+            false,
+            false,
+        ),
+        tool(
+            "app_tools",
+            "Discover the exact native tool schemas for a connected app. Filter by purpose or tool name; at most 40 matches are returned. Narrow the filter if truncated.",
+            json!({"type":"object","properties":{"session":session,"filter":{"type":"string"}},"required":["session"],"additionalProperties":false}),
+            true,
+            false,
+        ),
+        tool(
+            "app_read",
+            "Call a native tool that explicitly declares readOnlyHint=true, using its exact arguments from app_tools. Inspection and rendered previews return native content, including images.",
+            dispatch.clone(),
+            true,
+            false,
+        ),
+        tool(
+            "app_call",
+            "Call a native editing, file, export or other tool with exact arguments from app_tools. May change documents, replace files or access external services, depending on the selected app tool. Inspect before editing. Do not repeat a write after an unknown outcome.",
+            dispatch,
+            false,
+            true,
+        ),
+    ]
+}
+
+fn tool(name: &str, description: &str, schema: Value, read_only: bool, destructive: bool) -> Value {
+    let open_world = !read_only || name == "app_read";
+    json!({"name":name,"description":description,"inputSchema":schema,"annotations":{"readOnlyHint":read_only,"destructiveHint":destructive,"idempotentHint":read_only,"openWorldHint":open_world}})
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn initialized() -> Gateway {
+        let mut gateway = Gateway::new(Vec::new());
+        gateway.handle(json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}));
+        gateway
+    }
+
+    #[test]
+    fn notifications_never_execute_tools_or_return_replies() {
+        let mut gateway = initialized();
+        assert!(
+            gateway.handle(json!({"jsonrpc":"2.0","method":"tools/call","params":{"name":"app_connect","arguments":{"app":"figcraft"}}})).is_none()
+        );
+        assert!(gateway.sessions.is_empty());
+    }
+
+    #[test]
+    fn tools_are_stable_without_installed_apps_and_edits_are_not_read_only() {
+        let mut gateway = initialized();
+        let reply = gateway.handle(json!({"jsonrpc":"2.0","id":2,"method":"tools/list"})).unwrap();
+        let tools = reply["result"]["tools"].as_array().unwrap();
+        assert_eq!(tools.len(), 5);
+        assert_eq!(tools.iter().find(|t| t["name"] == "app_call").unwrap()["annotations"]["destructiveHint"], true);
+        assert_eq!(tools.iter().find(|t| t["name"] == "app_read").unwrap()["annotations"]["readOnlyHint"], true);
+    }
+
+    #[test]
+    fn refuses_invalid_arguments_before_launching_anything() {
+        let mut gateway = initialized();
+        for arguments in [
+            json!({"app":"figcraft","mode":"headless","port":9}),
+            json!({"app":"figcraft","port":0}),
+            json!({"app":"figcraft","port":65536}),
+            json!({"app":"figcraft","prot":12}),
+            json!({"app":"pdfcraft"}),
+        ] {
+            let reply =
+                gateway.handle(json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"app_connect","arguments":arguments}})).unwrap();
+            assert_eq!(reply["result"]["isError"], true);
+            assert!(gateway.sessions.is_empty());
+        }
+    }
+
+    #[test]
+    fn invalid_envelopes_and_unknown_methods_have_protocol_errors() {
+        let mut gateway = initialized();
+        assert_eq!(gateway.handle(json!({"id":1,"method":"ping"})).unwrap()["error"]["code"], -32600);
+        assert_eq!(gateway.handle(json!({"jsonrpc":"2.0","id":1,"method":"missing"})).unwrap()["error"]["code"], -32601);
+        assert_eq!(gateway.handle(json!({"jsonrpc":"2.0","id":1,"method":"ping","params":[]})).unwrap()["error"]["code"], -32602);
+    }
+
+    #[test]
+    fn an_empty_document_needs_a_successful_live_ui_probe() {
+        let error = json!({"content":[{"type":"text","text":"no document open"}],"isError":true});
+        let ready = json!({"content":[{"type":"text","text":"desktop state"}]});
+        assert_eq!(validate_live_inspection(error.clone(), Some(ready.clone())).unwrap(), Some(error.clone()));
+        assert!(validate_live_inspection(error.clone(), None).is_err());
+        assert!(validate_live_inspection(error.clone(), Some(error)).is_err());
+        assert_eq!(validate_live_inspection(ready, None).unwrap(), None);
+    }
+}
diff --git a/apps/artcraft-launcher-mcp/src/main.rs b/apps/artcraft-launcher-mcp/src/main.rs
new file mode 100644
--- /dev/null
+++ b/apps/artcraft-launcher-mcp/src/main.rs
@@ -0,0 +1,85 @@
+//! One local MCP connection for the installed Craft App Suite.
+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+
+mod discovery;
+mod gateway;
+mod transport;
+
+use std::io::{self, Write};
+use std::path::PathBuf;
+use std::process::ExitCode;
+
+use serde_json::{Value, json};
+
+const USAGE: &str = "ArtCraft Launcher MCP gateway\n\nUsage: artcraft-launcher-mcp [--cli-dir DIR]...\n\nRegister this executable once in an MCP client. apps_list scans installed Craft Apps on each\nrequest. app_connect opens a live FigCraft, DesignCraft or VectorCraft session, or an explicitly\nrequested headless session in another app. app_tools discovers the connected app's schemas;\napp_read and app_call dispatch its tools. --cli-dir adds a trusted folder of installed CLI binaries.\nSee docs/mcp.md for configuration, capabilities and limitations.\n";
+
+fn main() -> ExitCode {
+    match run() {
+        Ok(()) => ExitCode::SUCCESS,
+        Err(error) => {
+            eprintln!("ArtCraft Launcher MCP: {error}");
+            ExitCode::FAILURE
+        }
+    }
+}
+
+fn run() -> Result<(), String> {
+    let mut dirs = Vec::new();
+    let mut args = std::env::args().skip(1);
+    while let Some(arg) = args.next() {
+        match arg.as_str() {
+            "--help" | "-h" => {
+                print!("{USAGE}");
+                return Ok(());
+            }
+            "--version" | "-V" => {
+                println!("artcraft-launcher-mcp {}", env!("CARGO_PKG_VERSION"));
+                return Ok(());
+            }
+            "--cli-dir" => {
+                let dir = args.next().ok_or("--cli-dir needs a folder")?;
+                let dir = PathBuf::from(dir);
+                if !dir.is_dir() {
+                    return Err(format!("CLI folder does not exist: {}", dir.display()));
+                }
+                dirs.push(dir.canonicalize().map_err(|e| e.to_string())?);
+            }
+            _ => return Err(format!("unknown option {arg}\n{USAGE}")),
+        }
+    }
+    serve(&mut io::stdin().lock(), &mut io::stdout().lock(), &mut gateway::Gateway::new(dirs))
+}
+
+fn serve(input: &mut impl io::BufRead, output: &mut impl Write, gateway: &mut gateway::Gateway) -> Result<(), String> {
+    while let Some(line) = transport::read_frame(input)? {
+        let reply = match serde_json::from_slice::<Value>(&line) {
+            Ok(request) => gateway.handle(request),
+            Err(_) => Some(rpc_error(Value::Null, -32700, "Invalid JSON")),
+        };
+        if let Some(reply) = reply {
+            serde_json::to_writer(&mut *output, &reply).map_err(|e| e.to_string())?;
+            output.write_all(b"\n").and_then(|()| output.flush()).map_err(|e| e.to_string())?;
+        }
+    }
+    Ok(())
+}
+
+pub(crate) fn rpc_error(id: Value, code: i32, message: &str) -> Value {
+    json!({"jsonrpc":"2.0", "id":id, "error":{"code":code, "message":message}})
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn malformed_json_does_not_prevent_the_next_request() {
+        let input = b"{bad}\n{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"ping\"}\n";
+        let mut output = Vec::new();
+        serve(&mut input.as_slice(), &mut output, &mut gateway::Gateway::new(Vec::new())).unwrap();
+        let replies: Vec<Value> = output.split(|b| *b == b'\n').filter(|b| !b.is_empty()).map(|line| serde_json::from_slice(line).unwrap()).collect();
+        assert_eq!(replies[0]["error"]["code"], -32700);
+        assert_eq!(replies[1]["id"], 7);
+        assert_eq!(replies[1]["result"], json!({}));
+    }
+}
diff --git a/apps/artcraft-launcher-mcp/src/transport.rs b/apps/artcraft-launcher-mcp/src/transport.rs
new file mode 100644
--- /dev/null
+++ b/apps/artcraft-launcher-mcp/src/transport.rs
@@ -0,0 +1,237 @@
+//! Bounded stdio JSON-RPC for an owned native CLI. Never replay a request.
+
+use std::io::{BufRead, BufReader, Write};
+use std::process::{Child, Stdio};
+use std::sync::mpsc::{self, Receiver, SyncSender};
+use std::time::Duration;
+
+use serde_json::{Value, json};
+
+use crate::discovery::Program;
+
+pub(crate) const MAX_FRAME: usize = 32 << 20;
+const TIMEOUT: Duration = Duration::from_secs(60);
+
+#[derive(Debug)]
+enum Failure {
+    Connection(String),
+    Rejected(String),
+}
+
+pub(crate) fn read_frame(input: &mut impl BufRead) -> Result<Option<Vec<u8>>, String> {
+    read_limited(input, MAX_FRAME)
+}
+
+fn read_limited(input: &mut impl BufRead, limit: usize) -> Result<Option<Vec<u8>>, String> {
+    let mut frame = Vec::new();
+    loop {
+        let available = input.fill_buf().map_err(|e| e.to_string())?;
+        if available.is_empty() {
+            return if frame.is_empty() { Ok(None) } else { Err("Unterminated JSON-RPC frame".into()) };
+        }
+        let newline = available.iter().position(|b| *b == b'\n');
+        let count = newline.unwrap_or(available.len());
+        if count > limit.saturating_sub(frame.len()) {
+            return Err("JSON-RPC frame exceeds the size limit".into());
+        }
+        frame.extend_from_slice(available.get(..count).ok_or("Invalid frame boundary")?);
+        input.consume(count + usize::from(newline.is_some()));
+        if newline.is_some() {
+            return Ok(Some(frame));
+        }
+    }
+}
+
+pub(crate) struct Client {
+    child: Child,
+    input: SyncSender<Vec<u8>>,
+    writes: Receiver<Result<(), String>>,
+    replies: Receiver<Result<Value, String>>,
+    next_id: u64,
+    pub tools: Vec<Value>,
+    pub healthy: bool,
+}
+
+impl Client {
+    pub fn start(program: &Program) -> Result<Self, String> {
+        let mut child = program
+            .command()
+            .stdin(Stdio::piped())
+            .stdout(Stdio::piped())
+            .stderr(Stdio::inherit())
+            .spawn()
+            .map_err(|e| format!("Cannot start CLI: {e}"))?;
+        let mut input = match child.stdin.take() {
+            Some(input) => input,
+            None => {
+                let _ = child.kill();
+                let _ = child.wait();
+                return Err("CLI stdin is unavailable".into());
+            }
+        };
+        let (send_input, receive_input) = mpsc::sync_channel::<Vec<u8>>(1);
+        let (send_write, writes) = mpsc::sync_channel(1);
+        std::thread::spawn(move || {
+            while let Ok(bytes) = receive_input.recv() {
+                let result = input.write_all(&bytes).and_then(|()| input.write_all(b"\n")).and_then(|()| input.flush()).map_err(|e| e.to_string());
+                let failed = result.is_err();
+                if send_write.send(result).is_err() || failed {
+                    break;
+                }
+            }
+        });
+        let output = match child.stdout.take() {
+            Some(output) => output,
+            None => {
+                let _ = child.kill();
+                let _ = child.wait();
+                return Err("CLI stdout is unavailable".into());
+            }
+        };
+        let (sender, replies) = mpsc::sync_channel(1);
+        std::thread::spawn(move || {
+            let mut output = BufReader::new(output);
+            loop {
+                let result = read_frame(&mut output).and_then(|line| {
+                    line.ok_or_else(|| "CLI closed its output".to_string())
+                        .and_then(|line| serde_json::from_slice(&line).map_err(|e| format!("Invalid CLI JSON: {e}")))
+                });
+                let failed = result.is_err();
+                if sender.send(result).is_err() || failed {
+                    break;
+                }
+            }
+        });
+        let mut client = Self { child, input: send_input, writes, replies, next_id: 0, tools: Vec::new(), healthy: true };
+        client.request("initialize", json!({"protocolVersion":"2025-06-18", "capabilities":{}, "clientInfo":{"name":"artcraft-launcher-mcp", "version":env!("CARGO_PKG_VERSION")}}))?;
+        client.send(&json!({"jsonrpc":"2.0", "method":"notifications/initialized"}))?;
+        let mut cursor = None;
+        let mut pages = 0_u16;
+        let mut catalog_bytes = 0_usize;
+        loop {
+            pages += 1;
+            if pages > 128 {
+                return Err("CLI tool catalog exceeds 128 pages".into());
+            }
+            let params = cursor.as_ref().map_or_else(|| json!({}), |cursor| json!({"cursor":cursor}));
+            let result = client.request("tools/list", params)?;
+            let tools = result.get("tools").and_then(Value::as_array).ok_or("CLI returned no tool catalog")?;
+            if tools.len() > 4096_usize.saturating_sub(client.tools.len()) {
+                return Err("CLI tool catalog exceeds 4096 tools".into());
+            }
+            let bytes = serde_json::to_vec(tools).map_err(|e| e.to_string())?.len();
+            if bytes > MAX_FRAME.saturating_sub(catalog_bytes) {
+                return Err("CLI tool catalog exceeds the size limit".into());
+            }
+            catalog_bytes += bytes;
+            client.tools.extend(tools.iter().cloned());
+            match result.get("nextCursor").and_then(Value::as_str) {
+                Some(next) if cursor.as_deref() != Some(next) => cursor = Some(next.to_string()),
+                Some(_) => return Err("CLI repeated its catalog cursor".into()),
+                None => break,
+            }
+        }
+        Ok(client)
+    }
+
+    fn send(&mut self, request: &Value) -> Result<(), String> {
+        let bytes = serde_json::to_vec(request).map_err(|e| e.to_string())?;
+        if bytes.len() > MAX_FRAME {
+            return Err("Request exceeds the frame limit".into());
+        }
+        self.input.try_send(bytes).map_err(|e| format!("CLI input queue unavailable: {e}"))?;
+        self.writes.recv_timeout(TIMEOUT).map_err(|e| format!("CLI write timed out or stopped ({e}); inspect before retrying"))?
+    }
+
+    pub fn request(&mut self, method: &str, params: Value) -> Result<Value, String> {
+        if !self.healthy {
+            return Err("Session connection failed; reconnect explicitly. An earlier edit may have completed; inspect before retrying.".into());
+        }
+        self.next_id = self.next_id.checked_add(1).ok_or("Request id exhausted")?;
+        let id = self.next_id;
+        match self.exchange(id, method, params) {
+            Ok(result) => Ok(result),
+            Err(Failure::Rejected(error)) => Err(error),
+            Err(Failure::Connection(error)) => {
+                self.healthy = false;
+                Err(error)
+            }
+        }
+    }
+
+    fn exchange(&mut self, id: u64, method: &str, params: Value) -> Result<Value, Failure> {
+        self.send(&json!({"jsonrpc":"2.0", "id":id, "method":method, "params":params})).map_err(Failure::Connection)?;
+        let deadline = std::time::Instant::now() + TIMEOUT;
+        loop {
+            if std::time::Instant::now() >= deadline {
+                return Err(Failure::Connection("CLI reply timed out; the operation may have completed. Inspect before retrying.".into()));
+            }
+            let wait = deadline.saturating_duration_since(std::time::Instant::now());
+            let reply = self
+                .replies
+                .recv_timeout(wait)
+                .map_err(|e| Failure::Connection(format!("CLI reply unavailable ({e}); the operation may have completed. Inspect before retrying.")))?
+                .map_err(Failure::Connection)?;
+            if let Some(result) = response_result(id, reply)? {
+                return Ok(result);
+            }
+        }
+    }
+}
+
+fn response_result(id: u64, reply: Value) -> Result<Option<Value>, Failure> {
+    let invalid = |reason: &str| Failure::Connection(format!("{reason}; operation outcome is unknown"));
+    if reply.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
+        return Err(invalid("Invalid CLI JSON-RPC envelope"));
+    }
+    if reply.get("method").and_then(Value::as_str).is_some() && reply.get("id").is_none() {
+        // This increment does not advertise downstream progress or subscriptions.
+        return Ok(None);
+    }
+    if reply.get("id").and_then(Value::as_u64) != Some(id) {
+        return Err(invalid("CLI response id mismatch"));
+    }
+    match (reply.get("result"), reply.get("error")) {
+        (Some(result), None) => Ok(Some(result.clone())),
+        (None, Some(error)) => Err(Failure::Rejected(format!("CLI rejected the request: {error}"))),
+        _ => Err(invalid("CLI reply needs exactly one result or error")),
+    }
+}
+
+impl Drop for Client {
+    fn drop(&mut self) {
+        // Own only the CLI. An attached desktop application is deliberately left open.
+        let _ = self.child.kill();
+        let _ = self.child.wait();
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn frames_require_newlines_and_are_bounded_before_allocation() {
+        assert_eq!(read_limited(&mut b"{}\r\nrest".as_slice(), 3).unwrap(), Some(b"{}\r".to_vec()));
+        assert!(read_limited(&mut b"12345\n".as_slice(), 4).is_err());
+        assert!(read_limited(&mut b"{}".as_slice(), 4).is_err());
+        assert_eq!(read_limited(&mut b"".as_slice(), 4).unwrap(), None);
+    }
+
+    #[test]
+    fn mismatched_or_ambiguous_replies_are_connection_failures() {
+        for reply in
+            [json!({"jsonrpc":"2.0","id":2,"result":{}}), json!({"jsonrpc":"2.0","id":1,"result":{},"error":{}}), json!({"id":1,"result":{}})]
+        {
+            assert!(matches!(response_result(1, reply), Err(Failure::Connection(_))));
+        }
+    }
+
+    #[test]
+    fn rejections_are_distinct_from_lost_connections() {
+        let reply = json!({"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"invalid argument"}});
+        assert!(matches!(response_result(1, reply), Err(Failure::Rejected(_))));
+        assert_eq!(response_result(1, json!({"jsonrpc":"2.0","id":1,"result":null})).unwrap(), Some(Value::Null));
+        assert_eq!(response_result(1, json!({"jsonrpc":"2.0","method":"notifications/message","params":{}})).unwrap(), None);
+    }
+}
diff --git a/docs/mcp.md b/docs/mcp.md
new file mode 100644
--- /dev/null
+++ b/docs/mcp.md
@@ -0,0 +1,137 @@
+# One MCP connection for the installed Craft App Suite
+
+`artcraft-launcher-mcp` is an opt-in, desktop-only gateway for Codex and other MCP clients.
+Register it once. `apps_list` reuses ArtCraft Launcher's detection on every call, so an app
+installed later can be discovered without editing the client's configuration or restarting it.
+Listing apps does not open windows, download software or change client settings.
+
+This first development increment implements live adapters for **FigCraft, DesignCraft and
+VectorCraft**, plus explicit headless access to other detected Craft App CLIs. It is separate
+from the launcher GUI and from a dialog that writes individual MCP entries for installed apps.
+
+## Build and connect
+
+```sh
+cargo build --release -p artcraft-launcher-mcp
+codex mcp add artcraft -- /absolute/path/target/release/artcraft-launcher-mcp
+```
+
+On Windows the executable ends in `.exe`. Other MCP clients use the same absolute executable
+path as their stdio server command. Logs go to stderr; stdout contains only newline-delimited
+JSON-RPC. No API key is needed for the gateway itself.
+
+This executable is not yet included in release installers. Build it from the checkout and put
+it in a stable folder before registering it. Restart the MCP client once after initial
+registration; adding supported apps later does not change its five top-level tool schemas.
+
+The launcher detects installed apps from its manifest and the operating system. The gateway
+looks for `<app>-cli` beside the installed executable, in a tree's `bin` folder, then on `PATH`.
+`--cli-dir /trusted/folder` adds an explicit search folder and can be repeated. Use it for
+portable installations and macOS CLI archives separate from the `.app`. Only names from the
+built-in Craft App catalog are considered; no arbitrary program command is accepted over MCP.
+Flatpak CLIs are reached through the host when required. Missing CLIs are reported explicitly.
+AppImage CLI routing and live launching of sandboxed installation formats are not implemented.
+
+## Prompt workflow
+
+1. Call `apps_list` to discover installed apps and their current capabilities.
+2. Call `app_connect` with a catalog id, such as `figcraft`. The default mode is `live`.
+3. Keep its `session` handle and call `app_tools` with a filter for the exact native schemas.
+   At most 40 matches are returned; narrow a truncated search.
+4. Inspect with `app_read`, edit with `app_call`, then inspect and render a preview. Native
+   arguments are passed to the app, which validates its own tool schema.
+5. Save a native project and any requested exports using that app's tools.
+
+Example prompts: “Use FigCraft to inspect the selected frame” and “Use VectorCraft to create
+an editable logo and show a preview”. List apps again after installing another tool through
+ArtCraft Launcher.
+
+`app_read` accepts only tools whose advertised `annotations.readOnlyHint` is explicitly `true`.
+Other tools use `app_call`, conservatively annotated as potentially destructive and open-world:
+it can dispatch saves, exports, scripts or app tools that contact outside services. Normal MCP
+client permission policy applies; registration does not authorize every downstream action.
+Native results are forwarded unchanged, including image blocks, structured results and
+`isError`. Previews remain images rather than text containing base64.
+
+## Live and headless sessions
+
+Live mode opens a separate desktop instance on demand with a loopback control port and connects
+the app's own MCP CLI. Existing windows stay open. Native executable and macOS `.app` launching
+are implemented. To attach to an app already started with `--control PORT`, supply its
+operator-selected localhost port:
+
+```json
+{"app":"figcraft","mode":"live","port":47800}
+```
+
+CLI initialization, tool discovery and a native document inspection must succeed before a live
+connection is reported as successful. If document inspection fails (for example, DesignCraft's
+welcome screen has no document), a successful native desktop UI inspection is required instead.
+The connection result includes `initial_document_inspection_error`; inspect the error, then use
+the app's native tools to create or open a document before editing. No document is created merely
+by connecting.
+
+| App | Live adapter in this increment |
+| --- | --- |
+| FigCraft | `mcp --attach 127.0.0.1:PORT` |
+| DesignCraft | `mcp --connect 127.0.0.1:PORT` |
+| VectorCraft | `mcp --connect 127.0.0.1:PORT` |
+| Other catalog apps | Explicit `mode: "headless"`; live adapters need further implementation and release tests |
+
+Headless mode starts the app's native `mcp` session without a window. It must be requested
+explicitly, and the result labels it `headless`. It is never a fallback when live mode fails.
+PdfCraft's documented MCP has no desktop-attach mode, so it belongs in this category even when
+its desktop app is installed. Unsaved headless state belongs to its CLI process: save before
+closing the gateway. Explicitly replacing a failed headless session starts a fresh session; it
+does not recover its unsaved state. Closing the gateway terminates its owned CLI children,
+but does not close desktop applications or save their projects.
+
+Session handles select an app/endpoint, not an immutable document tab or window. The native CLI
+acts on that app's active document. Inspect immediately before editing, especially after manual
+changes or tab switches. Each gateway serves calls sequentially. Independent gateways can open
+separate desktop instances; shared session ownership and document conflict detection are
+future work.
+
+A gateway owns at most 32 sessions. Native tool discovery is capped at 4096 tools, 128 pages
+and 32 MiB across the catalog, in addition to the per-frame transport limit below.
+
+## Protocol and failure handling
+
+MCP `2025-06-18` over stdio is supported, with negotiation for `2025-03-26` and `2024-11-05`.
+The gateway implements initialization, ping and tools. It does not advertise subscriptions,
+background progress, server-initiated requests or cancellation in this increment.
+
+Incoming and downstream JSON lines are capped at 32 MiB before the newline. Oversized or
+unterminated frames close that transport. Child reads/writes have bounded queues and
+60-second waits. GUI startup waits up to 20 seconds. Clients should allow more than 80 seconds
+for a cold `app_connect`; a 120-second tool timeout is reasonable for testing.
+
+A lost reply, malformed frame, mismatched id or transport timeout marks the connection
+unhealthy. The operation may already have run. It is **never replayed**. Explicitly reconnect,
+inspect, then decide whether anything needs retrying. Live reconnection keeps the original
+endpoint. Native JSON-RPC argument rejections leave the connection healthy, so an agent can
+correct an argument. Tool errors retain the native app's `isError` information.
+
+Undo and batch atomicity are app-specific. The gateway does not enforce a uniform filesystem
+sandbox; client permissions and supported native access controls still apply. Cross-app
+transactions, automatic conversion, background sync and silent updates/restarts are not
+implemented.
+
+## Validation and next increments
+
+Run the repository gate (`cargo xtask ci`). Targeted checks:
+
+```sh
+cargo test -p artcraft-launcher-mcp
+cargo clippy -p artcraft-launcher-mcp --all-targets -- -D warnings
+```
+
+Unit tests cover bounded framing, malformed requests, initialization/notification handling,
+response correlation, read/edit annotations and detecting a CLI added after the first scan.
+Live checks should use scratch documents: connect, inspect, mutate, render, undo, save and
+reopen. Install another app with the MCP client open, then discover and connect to it.
+
+Next increments can add authenticated live adapters for other apps, document-level targeting,
+shared session ownership, app-specific file roots, release packaging and an optional launcher
+setup dialog. The standalone generative ArtCraft studio needs a separate automation/provider
+audit; it is not assumed to follow these CLI conventions.
