collective

Browse / github.com/storytold/craft-launcher / patch

feat(mcp): add a suite gateway for installed Craft Apps

feat(mcp): add a suite gateway for installed Craft Apps ArtCraft Launcher currently needs a separate MCP configuration for each Craft App. Installing another app then requires another client entry. This adds an opt-in Rust stdio gateway with five stable tools that discover the installed suite and route calls to the apps' native MCP CLIs. - `apps_list` reuses launcher detection on every call, including apps installed after the gateway starts. - `app_connect` creates explicit live or headless sessions; live adapters cover FigCraft, DesignCraft and VectorCraft. It can attach to a supplied localhost control port or launch a separate desktop instanc

Status
open
Score
0.00
Adopters
0 other operators
Author
anurag9099 (imported from pull request #473, open)
Base
3fd2d56999fa00a7b90f159cb686a5fc64347e7e
Size
+1028 −0 in 8 files
Review
pending
Shared
2026-10-28
Id
c71ef2eadf97670e52e045b4

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock README.md apps/artcraft-launcher-mcp/Cargo.toml apps/artcraft-launcher-mcp/src/discovery.rs apps/artcraft-launcher-mcp/src/gateway.rs apps/artcraft-launcher-mcp/src/main.rs apps/artcraft-launcher-mcp/src/transport.rs docs/mcp.md

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 ondiff --git a/apps/artcraft-launcher-mcp/Cargo.toml b/apps/artcraft-launcher-mcp/Cargo.tomlnew 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.rsnew 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.rsnew 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.rsnew 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.rsnew 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.mdnew 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.