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
@@ -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"@@ -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 onnew file mode 100644@@ -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"new file mode 100644@@ -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);+ }+}new file mode 100644@@ -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(¶ms) {+ 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);+ }+}new file mode 100644@@ -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!({}));+ }+}new file mode 100644@@ -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);+ }+}new file mode 100644@@ -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.