diff --git a/.github/workflows/control.yml b/.github/workflows/control.yml index 156be04dd..a3196a561 100644 --- a/.github/workflows/control.yml +++ b/.github/workflows/control.yml @@ -19,13 +19,15 @@ jobs: run: cargo test --lib app::control::tests - name: Validate Python adapters if: runner.os != 'Windows' - run: python3 -m py_compile docs/automation/live.py docs/automation/mcp_server.py + run: python3 -m py_compile docs/automation/live.py docs/automation/mcp_smoke.py mcp: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - run: python3 -m pip install -r docs/automation/requirements-mcp.txt - - run: python3 docs/automation/mcp_smoke.py + - uses: dtolnay/rust-toolchain@stable + - run: cargo test --lib mcp::tests + - run: cargo build + - run: python3 docs/automation/mcp_smoke.py target/debug/OpenCADStudio web: runs-on: ubuntu-latest steps: diff --git a/Cargo.lock b/Cargo.lock index cf694c99d..32d11b151 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,7 @@ version = "0.9.8" dependencies = [ "acadrust", "ashpd", + "base64", "bincode", "bytemuck", "cadkernel", diff --git a/Cargo.toml b/Cargo.toml index 149490736..c98b14966 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ winresource = "0.1" [dependencies] serde_json = "1" serde = { version = "1", features = ["derive"] } +base64 = "0.22" semver = "1" iced = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39", features = ["image", "svg", "advanced", "canvas", "smol", "markdown"] } iced_runtime = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } diff --git a/README.md b/README.md index b55069e7b..bac129f30 100644 --- a/README.md +++ b/README.md @@ -174,15 +174,16 @@ trunk serve ## Automation -The desktop binary supports one-shot conversion and a persistent headless server: +The desktop binary supports one-shot conversion, a persistent headless server, and a client-neutral MCP endpoint for AI applications: ```bash OpenCADStudio --export input.dwg output.dxf OpenCADStudio --serve OpenCADStudio --serve --port 4242 +OpenCADStudio --mcp ``` -The server exchanges one JSON object per line over standard input/output or a local TCP socket. See the [automation guide](docs/automation/README.md) and the included [Python client](docs/automation/ocs.py). +The automation server exchanges one JSON object per line over standard input/output or a local TCP socket. The MCP endpoint exposes the live desktop editor through the same tools to every compatible client without an additional runtime. See the [automation guide](docs/automation/README.md) and the included [Python client](docs/automation/ocs.py). ## Plugins diff --git a/docs/automation/README.md b/docs/automation/README.md index dbcb45aa8..6871b84d9 100644 --- a/docs/automation/README.md +++ b/docs/automation/README.md @@ -8,7 +8,22 @@ Operations include document state and activation, paged entity queries, layer/he The Web build exposes `ocs_control_submit(json)` and `ocs_control_take(ticket)`. They enter the same bounded GUI queue and return the same semantic responses; browser code polls the ticket until a response is present. -Run `./docs/automation/install.sh` once. It installs the release binary and pinned MCP environment in `~/.local/share/ocs-control`, registers the `opencadstudio` stdio server in the user's Codex configuration, and leaves source-independent launch paths. The personal `opencadstudio-control` skill tells later Codex sessions how to use it safely. +`OpenCADStudio --mcp` is the common local entry point for every MCP client. It is built into the application, uses stdio, launches the editor when needed, and has no Python or package-manager dependency. Tool definitions and safety instructions are served by MCP itself, so the drawing behavior is identical in every client. + +Run `./docs/automation/install.sh` once for a source build. It installs the release binary in `~/.local/share/ocs-control` and automatically connects installed Codex and Claude Code CLIs. Both registrations point to the same binary. Re-run an individual connection later with: + +```sh +~/.local/share/ocs-control/connect.sh codex +~/.local/share/ocs-control/connect.sh claude-code +``` + +Any other local MCP client uses the same command and argument. `connect.sh config` prints the portable configuration object to paste into clients that do not provide a registration CLI: + +```json +{"mcpServers":{"opencadstudio":{"command":"/path/to/OpenCADStudio","args":["--mcp"]}}} +``` + +Client-specific skills and extension manifests may improve discovery, but contain no drawing or protocol implementation. Adding another AI client therefore requires only registering this command; the OpenCADStudio executable and tools do not change. The legacy client is unchanged: diff --git a/docs/automation/connect.sh b/docs/automation/connect.sh new file mode 100755 index 000000000..5beae766f --- /dev/null +++ b/docs/automation/connect.sh @@ -0,0 +1,65 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +SERVER=${OCS_MCP_COMMAND:-"$ROOT/OpenCADStudio"} +NAME=opencadstudio + +require_server() { + [ -x "$SERVER" ] || { + printf 'OpenCADStudio MCP executable not found: %s\n' "$SERVER" >&2 + exit 1 + } +} + +connect_codex() { + SKILLS=${CODEX_HOME:-"$HOME/.codex"}/skills + SOURCE="$ROOT/integrations/codex/opencadstudio-control" + if [ -f "$SOURCE/SKILL.md" ]; then + mkdir -p "$SKILLS/opencadstudio-control/agents" + cp "$SOURCE/SKILL.md" "$SKILLS/opencadstudio-control/SKILL.md" + cp "$SOURCE/agents/openai.yaml" "$SKILLS/opencadstudio-control/agents/openai.yaml" + fi + codex mcp remove "$NAME" >/dev/null 2>&1 || true + codex mcp add "$NAME" -- "$SERVER" --mcp + printf 'Connected Codex to OpenCADStudio.\n' +} + +connect_claude_code() { + claude mcp remove "$NAME" >/dev/null 2>&1 || true + claude mcp add --transport stdio --scope user "$NAME" -- "$SERVER" --mcp + printf 'Connected Claude Code to OpenCADStudio.\n' +} + +print_config() { + ESCAPED=$(printf '%s' "$SERVER" | sed 's/\\/\\\\/g; s/"/\\"/g') + printf '{"mcpServers":{"%s":{"command":"%s","args":["--mcp"]}}}\n' "$NAME" "$ESCAPED" +} + +require_server +case ${1:-all} in + all) + FOUND=false + if command -v codex >/dev/null 2>&1; then connect_codex; FOUND=true; fi + if command -v claude >/dev/null 2>&1; then connect_claude_code; FOUND=true; fi + if [ "$FOUND" = false ]; then + printf 'No supported AI CLI was found. Use this configuration in any MCP client:\n' + print_config + fi + ;; + codex) + command -v codex >/dev/null 2>&1 || { printf 'Codex CLI is not installed.\n' >&2; exit 1; } + connect_codex + ;; + claude-code|claude) + command -v claude >/dev/null 2>&1 || { printf 'Claude Code is not installed.\n' >&2; exit 1; } + connect_claude_code + ;; + config) + print_config + ;; + *) + printf 'Usage: %s [all|codex|claude-code|config]\n' "$0" >&2 + exit 2 + ;; +esac diff --git a/docs/automation/install.sh b/docs/automation/install.sh index 2a8514fbb..2a6c64c69 100755 --- a/docs/automation/install.sh +++ b/docs/automation/install.sh @@ -8,13 +8,12 @@ cp "$ROOT/target/release/OpenCADStudio" "$DEST/OpenCADStudio.new" chmod 755 "$DEST/OpenCADStudio.new" mv -f "$DEST/OpenCADStudio.new" "$DEST/OpenCADStudio" cp "$ROOT/docs/automation/live.py" "$DEST/live.py" -cp "$ROOT/docs/automation/mcp_server.py" "$DEST/mcp_server.py" -uv venv "$DEST/venv" -uv pip install --python "$DEST/venv/bin/python" -r "$ROOT/docs/automation/requirements-mcp.txt" -SKILLS=${CODEX_HOME:-"$HOME/.codex"}/skills -mkdir -p "$SKILLS/opencadstudio-control/agents" -cp "$ROOT/integrations/codex/opencadstudio-control/SKILL.md" "$SKILLS/opencadstudio-control/SKILL.md" -cp "$ROOT/integrations/codex/opencadstudio-control/agents/openai.yaml" "$SKILLS/opencadstudio-control/agents/openai.yaml" -codex mcp remove opencadstudio >/dev/null 2>&1 || true -codex mcp add opencadstudio -- "$DEST/venv/bin/python" "$DEST/mcp_server.py" -printf 'Installed OpenCADStudio and its persistent Codex MCP connection.\n' +cp "$ROOT/docs/automation/connect.sh" "$DEST/connect.sh" +chmod 755 "$DEST/connect.sh" +mkdir -p "$DEST/integrations/codex/opencadstudio-control/agents" +cp "$ROOT/integrations/codex/opencadstudio-control/SKILL.md" "$DEST/integrations/codex/opencadstudio-control/SKILL.md" +cp "$ROOT/integrations/codex/opencadstudio-control/agents/openai.yaml" "$DEST/integrations/codex/opencadstudio-control/agents/openai.yaml" +rm -f "$DEST/mcp_server.py" +rm -rf "$DEST/venv" +"$DEST/connect.sh" all +printf 'Installed the client-neutral OpenCADStudio MCP runtime.\n' diff --git a/docs/automation/mcp_server.py b/docs/automation/mcp_server.py deleted file mode 100644 index c16b022f6..000000000 --- a/docs/automation/mcp_server.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Local stdio MCP adapter for a running OpenCADStudio GUI.""" -from pathlib import Path -import tempfile -from typing import Any -from mcp.server.fastmcp import FastMCP, Image -from live import LiveOcs, READ_OPS, launch, sessions - -mcp = FastMCP("OpenCADStudio", instructions="Use ocs_sessions, then ocs_read(state/commands) before editing. All changes target the actual GUI document. Preserve session_id, document_id and revisions. waiting_input means the command or dialog still needs input. Poll running operations; never replay a mutation with a new request_id after a timeout. Geometry is handled by OCS and its kernel.") -clients: dict[str, LiveOcs] = {} - - -def client(session_id: str) -> LiveOcs: - if session_id not in clients: - clients[session_id] = LiveOcs(session_id) - return clients[session_id] - - -@mcp.tool() -def ocs_sessions(launch_if_none: bool = True) -> list[dict[str, Any]]: - """List real OCS GUI sessions and documents. Launch installed OCS if none is running.""" - available = launch() if launch_if_none else sessions() - return [s["state"] for s in available] - - -@mcp.tool() -def ocs_read(session_id: str, op: str = "state", parameters: dict[str, Any] | None = None) -> dict[str, Any]: - """Read state, commands/actions, query (type/layer/offset/limit), entities, layers, header, properties, measure(handles), history, events(after), operation(request_id).""" - if op not in READ_OPS: - raise ValueError("Use ocs_execute for mutations") - return client(session_id).request({**(parameters or {}), "op": op}) - - -@mcp.tool() -def ocs_execute(session_id: str, request: dict[str, Any], wait_seconds: float = 30) -> dict[str, Any]: - """Execute one semantic action. op: new/open(path)/activate(document_id)/start(cmd)/run(cmd)/input(kind,text or point)/select(handles)/property(field,value)/action(name)/save(path)/undo/redo/cancel. Input kinds: text, token, point(space wcs/ucs/relative), entity or structure(handle,point), selection, enter. Supply document_id and revision from state. request_id is the idempotency key. Accepted/running is not completion. Query operation after a timeout.""" - return client(session_id).request(request, wait_seconds) - - -@mcp.tool() -def ocs_capture(session_id: str) -> Image: - """Capture the actual current OCS window as PNG for visual verification.""" - with tempfile.TemporaryDirectory(prefix="ocs-capture-") as directory: - path = Path(directory) / "window.png" - result = client(session_id).request({"op": "capture", "path": str(path)}) - if not result.get("ok") or result.get("status") != "completed": - raise RuntimeError(str(result)) - return Image(data=path.read_bytes(), format="png") - - -if __name__ == "__main__": - mcp.run(transport="stdio") diff --git a/docs/automation/mcp_smoke.py b/docs/automation/mcp_smoke.py index bedbef3d8..391dca265 100644 --- a/docs/automation/mcp_smoke.py +++ b/docs/automation/mcp_smoke.py @@ -1,19 +1,47 @@ -"""Protocol smoke test for the bundled MCP adapter.""" -import asyncio +"""Protocol smoke test for the native client-neutral MCP endpoint.""" +import json from pathlib import Path +import subprocess import sys -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client -async def main() -> None: - server = Path(__file__).with_name("mcp_server.py") - params = StdioServerParameters(command=sys.executable, args=[str(server)]) - async with stdio_client(params) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - tools = await session.list_tools() - names = {tool.name for tool in tools.tools} - assert names == {"ocs_sessions", "ocs_read", "ocs_execute", "ocs_capture"}, names +def main() -> None: + server = Path(sys.argv[1] if len(sys.argv) > 1 else "target/debug/OpenCADStudio").resolve() + process = subprocess.Popen( + [str(server), "--mcp"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + assert process.stdin and process.stdout + + def request(payload: dict) -> dict: + process.stdin.write(json.dumps(payload) + "\n") + process.stdin.flush() + return json.loads(process.stdout.readline()) + + initialized = request({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "smoke", "version": "1"}}, + }) + assert initialized["result"]["serverInfo"]["name"] == "OpenCADStudio" + assert initialized["result"]["instructions"] + process.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n") + process.stdin.flush() + tools = request({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}) + names = {tool["name"] for tool in tools["result"]["tools"]} + assert names == {"ocs_sessions", "ocs_read", "ocs_execute", "ocs_capture"}, names + sessions = request({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": {"name": "ocs_sessions", "arguments": {"launch_if_none": False}}, + }) + assert not sessions["result"].get("isError"), sessions + process.stdin.close() + assert process.wait(timeout=5) == 0 if __name__ == "__main__": - asyncio.run(main()) + main() diff --git a/docs/automation/requirements-mcp.txt b/docs/automation/requirements-mcp.txt deleted file mode 100644 index 2eea1d5fb..000000000 --- a/docs/automation/requirements-mcp.txt +++ /dev/null @@ -1 +0,0 @@ -mcp==1.29.1 diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 32b936d5d..3789f6366 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1823,6 +1823,8 @@ impl OpenCADStudio { dyn_capturing, &self.history_content, self.win_size.1, + self.control.enabled, + self.control_busy(), ); let center_stack: Element<'_, Message> = if thumbnail_capture_clean { workspace @@ -1987,8 +1989,6 @@ impl OpenCADStudio { self.quick_properties, tab.scene.selection_filter_active(), self.selection_cycling, - self.control.enabled, - self.control_busy(), &self.statusbar_config, status_menu_data, ) diff --git a/src/cli.rs b/src/cli.rs index 845c08dc5..5a56a440c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,7 +1,8 @@ //! Command-line interface. //! -//! Parsing lives here; `main` interprets the result. Three run modes split out +//! Parsing lives here; `main` interprets the result. Four run modes split out //! of the parsed args: +//! - `--mcp` local stdio MCP server for AI clients //! - `--serve` headless JSON automation server (see `app::serve`) //! - `--export IN OUT` one-shot headless format conversion, then exit //! - otherwise launch the GUI editor, configured via [`GuiConfig`] @@ -59,6 +60,10 @@ pub struct Cli { #[arg(long)] pub serve: bool, + /// Expose the running desktop editor to AI clients over MCP stdio. + #[arg(long)] + pub mcp: bool, + /// TCP port for --serve (defaults to stdin/stdout). #[arg(long, value_name = "PORT")] pub port: Option, diff --git a/src/lib.rs b/src/lib.rs index 208754128..5a8bda72d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,8 @@ pub mod i18n; pub mod io; #[cfg(not(target_arch = "wasm32"))] pub(crate) mod network; +#[cfg(not(target_arch = "wasm32"))] +pub mod mcp; pub mod modules; pub mod patreon; pub mod discussions; diff --git a/src/main.rs b/src/main.rs index 6c893d837..9540e4972 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,8 @@ mod i18n; mod io; #[cfg(not(target_arch = "wasm32"))] mod network; +#[cfg(not(target_arch = "wasm32"))] +mod mcp; mod modules; mod patreon; mod discussions; @@ -75,6 +77,14 @@ fn main() -> iced::Result { std::process::exit(if ok { 0 } else { 1 }); } + // MCP is a client-neutral local entry point. It uses only stdin, + // stdout and the authenticated GUI bridge, so it must run before any + // logging or graphics setup can write to the protocol stream. + if args.mcp { + mcp::run(); + return Ok(()); + } + // Opt-in logging. `--log LEVEL` seeds RUST_LOG; the subscriber then // surfaces wgpu / iced / winit diagnostics that are otherwise silent. if let Some(level) = &args.log { diff --git a/src/mcp.rs b/src/mcp.rs new file mode 100644 index 000000000..5ab92a172 --- /dev/null +++ b/src/mcp.rs @@ -0,0 +1,595 @@ +//! Client-neutral MCP adapter for the live desktop editor. +//! +//! `OpenCADStudio --mcp` speaks MCP over stdio. All drawing work is forwarded +//! to the authenticated GUI control bridge; this module contains no geometry. + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use serde::Deserialize; +use serde_json::{json, Map, Value}; +use std::{ + collections::HashMap, + fs::{File, OpenOptions}, + io::{self, BufRead, BufReader, Read, Write}, + net::TcpStream, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + thread, + time::{Duration, Instant}, +}; + +const PROTOCOL_VERSION: &str = "2025-11-25"; +const MODERN_PROTOCOL_VERSION: &str = "2026-07-28"; +const MAX_REQUEST: usize = 1_048_576; +const MAX_RESPONSE: u64 = 16 * 1024 * 1024; +const INSTRUCTIONS: &str = "Use ocs_sessions first; it opens OpenCADStudio when needed. Read state and commands before editing. Preserve session_id, document_id, revision, selection and request_id. After a timeout, query the existing operation and never replay a mutation with a new request_id. waiting_input and running are not completion. Let OpenCADStudio and its geometry kernel calculate geometry. Verify important results with queries and ocs_capture, and save only to an explicit path."; +const READ_OPS: &[&str] = &[ + "state", + "hello", + "query", + "entities", + "layers", + "header", + "properties", + "measure", + "history", + "commands", + "events", + "operation", +]; + +#[derive(Clone, Deserialize)] +struct Descriptor { + session_id: String, + port: u16, + token: String, +} + +struct GuiClient { + descriptor: Descriptor, + state: Value, + client_id: String, +} + +fn random_id() -> Result { + let mut bytes = [0u8; 16]; + getrandom::fill(&mut bytes).map_err(|error| error.to_string())?; + Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect()) +} + +#[cfg(unix)] +fn private_descriptor(path: &Path) -> bool { + use std::os::unix::fs::MetadataExt; + let Ok(file) = path.metadata() else { + return false; + }; + let Some(parent) = path.parent() else { + return false; + }; + let Ok(directory) = parent.metadata() else { + return false; + }; + file.uid() == directory.uid() && file.mode() & 0o077 == 0 +} + +#[cfg(not(unix))] +fn private_descriptor(_: &Path) -> bool { + true +} + +fn exchange(descriptor: &Descriptor, request: Value, timeout: Duration) -> Result { + let mut object = request + .as_object() + .cloned() + .ok_or_else(|| "GUI request must be an object".to_string())?; + object.insert("token".into(), Value::String(descriptor.token.clone())); + object.insert( + "session_id".into(), + Value::String(descriptor.session_id.clone()), + ); + object.insert("protocol".into(), Value::from(1)); + let mut wire = serde_json::to_vec(&Value::Object(object)).map_err(|e| e.to_string())?; + wire.push(b'\n'); + if wire.len() > MAX_REQUEST { + return Err("Request exceeds 1 MiB".into()); + } + + let mut stream = + TcpStream::connect(("127.0.0.1", descriptor.port)).map_err(|error| error.to_string())?; + stream + .set_read_timeout(Some(timeout)) + .map_err(|error| error.to_string())?; + stream + .set_write_timeout(Some(timeout)) + .map_err(|error| error.to_string())?; + stream.write_all(&wire).map_err(|error| error.to_string())?; + + let mut response = String::new(); + BufReader::new(stream) + .take(MAX_RESPONSE + 1) + .read_to_string(&mut response) + .map_err(|error| error.to_string())?; + if response.is_empty() || response.len() as u64 > MAX_RESPONSE { + return Err("No valid OCS response; query request_id before retrying a mutation".into()); + } + serde_json::from_str(response.trim_end()).map_err(|error| error.to_string()) +} + +fn descriptors() -> Result, String> { + let directory = crate::config::config_dir() + .ok_or_else(|| "No user configuration directory".to_string())? + .join("automation"); + let Ok(entries) = directory.read_dir() else { + return Ok(Vec::new()); + }; + let mut paths: Vec = entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("json")) + .collect(); + paths.sort(); + + let mut found = Vec::new(); + for path in paths { + if !private_descriptor(&path) { + continue; + } + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + let Ok(descriptor) = serde_json::from_str::(&text) else { + continue; + }; + let Ok(state) = exchange(&descriptor, json!({"op":"hello"}), Duration::from_secs(1)) else { + continue; + }; + if state["ok"].as_bool() == Some(true) + && state["session_id"].as_str() == Some(descriptor.session_id.as_str()) + { + found.push((descriptor, state)); + } + } + Ok(found) +} + +fn log_file() -> Result { + let directory = crate::config::config_dir() + .ok_or_else(|| "No user configuration directory".to_string())? + .join("automation"); + std::fs::create_dir_all(&directory).map_err(|error| error.to_string())?; + OpenOptions::new() + .create(true) + .append(true) + .open(directory.join("gui.log")) + .map_err(|error| error.to_string()) +} + +fn start_gui() -> Result { + let executable = std::env::current_exe().map_err(|error| error.to_string())?; + let log = log_file()?; + let stderr = log.try_clone().map_err(|error| error.to_string())?; + Command::new(executable) + .arg("--new-instance") + .stdin(Stdio::null()) + .stdout(Stdio::from(log)) + .stderr(Stdio::from(stderr)) + .spawn() + .map_err(|error| error.to_string()) +} + +fn sessions(launch_if_none: bool) -> Result, String> { + let mut available = descriptors()?; + if available.is_empty() && launch_if_none { + let mut child = start_gui()?; + let deadline = Instant::now() + Duration::from_secs(30); + while Instant::now() < deadline { + if let Some(status) = child.try_wait().map_err(|error| error.to_string())? { + return Err(format!("OpenCADStudio exited while starting ({status})")); + } + thread::sleep(Duration::from_millis(200)); + available = descriptors()?; + if !available.is_empty() { + break; + } + } + if available.is_empty() { + return Err("OpenCADStudio is still starting; call ocs_sessions again".into()); + } + } + Ok(available.into_iter().map(|(_, state)| state).collect()) +} + +fn insert_default(object: &mut Map, key: &str, value: Value) { + if !object.contains_key(key) { + object.insert(key.into(), value); + } +} + +impl GuiClient { + fn connect(session_id: &str) -> Result { + let mut matching: Vec<_> = descriptors()? + .into_iter() + .filter(|(descriptor, _)| descriptor.session_id == session_id) + .collect(); + if matching.len() != 1 { + return Err(format!( + "Choose session_id from ocs_sessions; found {} matching sessions", + matching.len() + )); + } + let (descriptor, state) = matching.remove(0); + Ok(Self { + descriptor, + state, + client_id: random_id()?, + }) + } + + fn request(&mut self, request: Value, wait_seconds: f64) -> Result { + let mut object = request + .as_object() + .cloned() + .ok_or_else(|| "request must be an object".to_string())?; + let op = object + .get("op") + .and_then(Value::as_str) + .ok_or_else(|| "request must contain op".to_string())? + .to_string(); + if !READ_OPS.contains(&op.as_str()) { + insert_default(&mut object, "request_id", Value::String(random_id()?)); + insert_default( + &mut object, + "client_id", + Value::String(self.client_id.clone()), + ); + insert_default( + &mut object, + "document_id", + self.state["document_id"].clone(), + ); + insert_default(&mut object, "revision", self.state["revision"].clone()); + if ["input", "property", "run", "action", "save", "undo", "redo"].contains(&op.as_str()) + { + insert_default(&mut object, "selection", self.state["selection"].clone()); + } + } + + let request_id = object.get("request_id").cloned(); + let mut response = exchange( + &self.descriptor, + Value::Object(object), + Duration::from_secs(15), + )?; + let wait = wait_seconds.clamp(0.0, 60.0); + let deadline = Instant::now() + Duration::from_secs_f64(wait); + while matches!(response["status"].as_str(), Some("accepted" | "running")) + && Instant::now() < deadline + { + let Some(request_id) = request_id.clone() else { + break; + }; + thread::sleep(Duration::from_millis(50)); + response = exchange( + &self.descriptor, + json!({"op":"operation","request_id":request_id}), + Duration::from_secs(15), + )?; + } + if response.get("state").is_some() { + self.state = response["state"].clone(); + } else if matches!(op.as_str(), "hello" | "state") && response["ok"].as_bool() == Some(true) + { + self.state = response.clone(); + } + Ok(response) + } +} + +fn client<'a>( + clients: &'a mut HashMap, + session_id: &str, +) -> Result<&'a mut GuiClient, String> { + if !clients.contains_key(session_id) { + clients.insert(session_id.into(), GuiClient::connect(session_id)?); + } + Ok(clients.get_mut(session_id).expect("client inserted")) +} + +fn required_string<'a>(arguments: &'a Value, key: &str) -> Result<&'a str, String> { + arguments[key] + .as_str() + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("Missing {key}")) +} + +fn call_tool( + name: &str, + arguments: &Value, + clients: &mut HashMap, +) -> Result { + match name { + "ocs_sessions" => { + let launch = arguments["launch_if_none"].as_bool().unwrap_or(true); + Ok(Value::Array(sessions(launch)?)) + } + "ocs_read" => { + let session_id = required_string(arguments, "session_id")?; + let op = arguments["op"].as_str().unwrap_or("state"); + if !READ_OPS.contains(&op) { + return Err("Use ocs_execute for mutations".into()); + } + let mut request = arguments["parameters"] + .as_object() + .cloned() + .unwrap_or_default(); + request.insert("op".into(), Value::String(op.into())); + client(clients, session_id)?.request(Value::Object(request), 30.0) + } + "ocs_execute" => { + let session_id = required_string(arguments, "session_id")?; + let request = arguments["request"] + .as_object() + .cloned() + .map(Value::Object) + .ok_or_else(|| "Missing request object".to_string())?; + let wait = arguments["wait_seconds"].as_f64().unwrap_or(30.0); + client(clients, session_id)?.request(request, wait) + } + "ocs_capture" => { + let session_id = required_string(arguments, "session_id")?; + let path = std::env::temp_dir().join(format!("ocs-capture-{}.png", random_id()?)); + let result = client(clients, session_id)? + .request(json!({"op":"capture","path":path.to_string_lossy()}), 30.0)?; + if result["ok"].as_bool() != Some(true) + || result["status"].as_str() != Some("completed") + { + return Err(result.to_string()); + } + let bytes = std::fs::read(&path).map_err(|error| error.to_string())?; + let _ = std::fs::remove_file(path); + Ok(json!({"$image":BASE64.encode(bytes)})) + } + _ => Err(format!("Unknown tool: {name}")), + } +} + +fn tool_definitions() -> Value { + json!([ + { + "name":"ocs_sessions", + "description":"List real OpenCADStudio GUI sessions and documents. Launch the installed editor if none is running.", + "inputSchema":{"type":"object","properties":{"launch_if_none":{"type":"boolean","default":true}},"additionalProperties":false}, + "annotations":{"title":"List OCS sessions","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} + }, + { + "name":"ocs_read", + "description":"Read state, commands/actions, query, entities, layers, header, properties, measurements, history, events or operation status from a live OCS session.", + "inputSchema":{"type":"object","properties":{"session_id":{"type":"string"},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object"}},"required":["session_id"],"additionalProperties":false}, + "annotations":{"title":"Read OCS state","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} + }, + { + "name":"ocs_execute", + "description":"Execute one semantic OCS action. Use state document_id and revision. request_id is the idempotency key. Accepted, running and waiting_input are not completion.", + "inputSchema":{"type":"object","properties":{"session_id":{"type":"string"},"request":{"type":"object"},"wait_seconds":{"type":"number","minimum":0,"maximum":60,"default":30}},"required":["session_id","request"],"additionalProperties":false}, + "annotations":{"title":"Execute OCS action","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false} + }, + { + "name":"ocs_capture", + "description":"Capture the actual current OpenCADStudio window as PNG for visual verification.", + "inputSchema":{"type":"object","properties":{"session_id":{"type":"string"}},"required":["session_id"],"additionalProperties":false}, + "annotations":{"title":"Capture OCS window","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} + } + ]) +} + +fn tool_result(value: Value) -> Value { + if let Some(image) = value.get("$image").and_then(Value::as_str) { + return json!({"content":[{"type":"image","data":image,"mimeType":"image/png"}]}); + } + let structured = if value.is_object() { + value.clone() + } else { + json!({"result":value.clone()}) + }; + json!({ + "content":[{"type":"text","text":value.to_string()}], + "structuredContent":structured + }) +} + +fn error_result(message: impl ToString) -> Value { + json!({"content":[{"type":"text","text":message.to_string()}],"isError":true}) +} + +fn response(id: Value, result: Value) -> Value { + json!({"jsonrpc":"2.0","id":id,"result":result}) +} + +fn server_info() -> Value { + json!({"name":"OpenCADStudio","title":"Open CAD Studio","version":env!("CARGO_PKG_VERSION")}) +} + +fn modern_request(params: &Value) -> bool { + params["_meta"]["io.modelcontextprotocol/protocolVersion"].as_str() + == Some(MODERN_PROTOCOL_VERSION) +} + +fn protocol_result(mut result: Value, modern: bool) -> Value { + if modern { + let object = result + .as_object_mut() + .expect("MCP results are JSON objects"); + object + .entry("resultType") + .or_insert_with(|| Value::String("complete".into())); + object.insert( + "_meta".into(), + json!({"io.modelcontextprotocol/serverInfo":server_info()}), + ); + } + result +} + +fn rpc_error(id: Value, code: i64, message: impl ToString) -> Value { + json!({"jsonrpc":"2.0","id":id,"error":{"code":code,"message":message.to_string()}}) +} + +fn handle_message(message: Value, clients: &mut HashMap) -> Option { + let id = message.get("id").cloned(); + let method = message.get("method").and_then(Value::as_str)?; + if id.is_none() { + return None; + } + let id = id.unwrap(); + let params = message.get("params").cloned().unwrap_or_else(|| json!({})); + let modern = modern_request(¶ms); + Some(match method { + "initialize" => { + let requested = params["protocolVersion"] + .as_str() + .unwrap_or(PROTOCOL_VERSION); + let protocol = + if ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"].contains(&requested) { + requested + } else { + PROTOCOL_VERSION + }; + response( + id, + json!({ + "protocolVersion":protocol, + "capabilities":{"tools":{"listChanged":false}}, + "serverInfo":server_info(), + "instructions":INSTRUCTIONS + }), + ) + } + "server/discover" => response( + id, + protocol_result( + json!({ + "supportedVersions":[MODERN_PROTOCOL_VERSION,PROTOCOL_VERSION], + "capabilities":{"tools":{}}, + "instructions":INSTRUCTIONS + }), + true, + ), + ), + "ping" => response(id, protocol_result(json!({}), modern)), + "tools/list" => response( + id, + protocol_result(json!({"tools":tool_definitions()}), modern), + ), + "tools/call" => { + let Some(name) = params["name"].as_str() else { + return Some(rpc_error(id, -32602, "Missing tool name")); + }; + let arguments = params + .get("arguments") + .cloned() + .unwrap_or_else(|| json!({})); + let result = call_tool(name, &arguments, clients) + .map(tool_result) + .unwrap_or_else(error_result); + response(id, protocol_result(result, modern)) + } + _ => rpc_error(id, -32601, format!("Method not found: {method}")), + }) +} + +/// Run the MCP stdio loop until the client closes stdin. +pub fn run() { + let stdin = io::stdin(); + let stdout = io::stdout(); + let mut output = stdout.lock(); + let mut clients = HashMap::new(); + for line in stdin.lock().lines() { + let response = match line { + Ok(line) if !line.trim().is_empty() => match serde_json::from_str::(&line) { + Ok(message) => handle_message(message, &mut clients), + Err(error) => Some(rpc_error(Value::Null, -32700, error)), + }, + Ok(_) => None, + Err(error) => { + eprintln!("MCP input error: {error}"); + break; + } + }; + if let Some(response) = response { + if writeln!(output, "{response}") + .and_then(|_| output.flush()) + .is_err() + { + break; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn advertises_the_shared_tools() { + let tools = tool_definitions(); + let names: Vec<_> = tools + .as_array() + .unwrap() + .iter() + .map(|tool| tool["name"].as_str().unwrap()) + .collect(); + assert_eq!( + names, + ["ocs_sessions", "ocs_read", "ocs_execute", "ocs_capture"] + ); + } + + #[test] + fn negotiates_and_lists_tools() { + let mut clients = HashMap::new(); + let initialized = handle_message( + json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25"}}), + &mut clients, + ) + .unwrap(); + assert_eq!(initialized["result"]["protocolVersion"], "2025-11-25"); + assert!(initialized["result"]["instructions"] + .as_str() + .unwrap() + .contains("geometry kernel")); + + let listed = handle_message( + json!({"jsonrpc":"2.0","id":2,"method":"tools/list"}), + &mut clients, + ) + .unwrap(); + assert_eq!(listed["result"]["tools"].as_array().unwrap().len(), 4); + } + + #[test] + fn supports_modern_stateless_discovery() { + let mut clients = HashMap::new(); + let discovered = handle_message( + json!({"jsonrpc":"2.0","id":"discover","method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}), + &mut clients, + ) + .unwrap(); + assert_eq!(discovered["result"]["resultType"], "complete"); + assert_eq!(discovered["result"]["supportedVersions"][0], "2026-07-28"); + assert_eq!( + discovered["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"], + "OpenCADStudio" + ); + } + + #[test] + fn read_tool_rejects_mutations_before_connecting() { + let mut clients = HashMap::new(); + let called = handle_message( + json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ocs_read","arguments":{"session_id":"missing","op":"save"}}}), + &mut clients, + ) + .unwrap(); + assert_eq!(called["result"]["isError"], true); + } +} diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index 0b689b267..1e5c94ad7 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -42,6 +42,25 @@ fn cmd_input_id() -> iced::widget::Id { iced::widget::Id::new(CMD_INPUT_ID) } +fn mcp_status(enabled: bool, busy: bool) -> (&'static str, Color) { + if !enabled { + ( + "MCP control is off", + Color::from_rgb(0.90, 0.35, 0.35), + ) + } else if busy { + ( + "MCP is handling a request", + Color::from_rgb(0.95, 0.72, 0.25), + ) + } else { + ( + "MCP control is ready", + Color::from_rgb(0.35, 0.85, 0.55), + ) + } +} + /// Drop a prompt's "[A / B / …]" option listing — used for the pinned line /// when the same options render as clickable buttons beside it. Collapses the /// leftover double spaces and the orphaned " :" the removal leaves behind. @@ -506,6 +525,8 @@ impl CommandLine { dyn_capturing: bool, history_content: &'a text_editor::Content, window_height: f32, + control_enabled: bool, + control_busy: bool, ) -> Element<'a, Message> { // Only the most recent entries pushed within the last few // seconds show on the overlay. The dropdown button keeps the @@ -715,7 +736,24 @@ impl CommandLine { .align_y(iced::alignment::Vertical::Center), ] .width(Length::Fill); - let input_row = row![prompt, literal_btn, input_with_history] + let (mcp_tooltip, mcp_color) = mcp_status(control_enabled, control_busy); + let mcp_btn = button(text("MCP").size(11)) + .on_press(Message::ControlToggle) + .style(move |theme: &Theme, status| { + let mut style = button::subtle(theme, status); + style.background = Some(Background::Color(mcp_color)); + style.text_color = Color::from_rgb(0.08, 0.08, 0.10); + style.border.color = mcp_color; + style.border.width = 1.0; + style + }) + .padding([2, 8]) + .width(Length::Fixed(50.0)); + let mcp_tip = container(text(mcp_tooltip).size(11)) + .padding([3, 6]) + .style(container::bordered_box); + let mcp_btn = tooltip(mcp_btn, mcp_tip, tooltip::Position::Top).gap(4); + let input_row = row![prompt, literal_btn, input_with_history, mcp_btn] .spacing(4) .align_y(iced::Center); @@ -975,7 +1013,7 @@ fn history_highlight_format( #[cfg(test)] mod tests { - use super::{ranked_matches, CommandLine}; + use super::{mcp_status, ranked_matches, CommandLine}; use crate::t; use rustc_hash::FxHashMap; @@ -986,6 +1024,13 @@ mod tests { .collect() } + #[test] + fn mcp_status_distinguishes_off_ready_and_busy() { + assert_eq!(mcp_status(false, false).0, "MCP control is off"); + assert_eq!(mcp_status(true, false).0, "MCP control is ready"); + assert_eq!(mcp_status(true, true).0, "MCP is handling a request"); + } + #[test] fn dynamic_plugin_commands_surface_in_autocomplete() { let none = FxHashMap::default(); diff --git a/src/ui/statusbar/mod.rs b/src/ui/statusbar/mod.rs index f3fde6804..0a84008c1 100644 --- a/src/ui/statusbar/mod.rs +++ b/src/ui/statusbar/mod.rs @@ -122,8 +122,6 @@ impl StatusBar { selection_filter_active: bool, // Whether selection cycling is enabled. selection_cycling: bool, - control_enabled: bool, - control_busy: bool, // Which pills the user has chosen to show on the bar. config: &'a StatusBarConfig, menu_data: StatusMenuData<'a>, @@ -495,35 +493,6 @@ impl StatusBar { // use the remaining space on the final tab row when they fit; otherwise // WrapBar adds another right-aligned row. let mut left: Vec> = Vec::new(); - let control_label = if !control_enabled { - "Control: off" - } else if control_busy { - "Control: busy" - } else { - "Control: ready" - }; - let control_color = if !control_enabled { - Color::from_rgb(0.90, 0.35, 0.35) - } else if control_busy { - Color::from_rgb(0.95, 0.72, 0.25) - } else { - Color::from_rgb(0.35, 0.85, 0.55) - }; - left.push( - button(text(control_label).size(11)) - .on_press(Message::ControlToggle) - .style(move |theme: &Theme, status| { - let mut style = button::subtle(theme, status); - style.background = Some(Background::Color(control_color)); - style.text_color = Color::from_rgb(0.08, 0.08, 0.10); - style.border.color = control_color; - style.border.width = 1.0; - style - }) - .padding([4, 8]) - .width(Length::Fixed(100.0)) - .into(), - ); left.push(menu_btn); if show_layout_tabs { let reorderable_layouts: Arc<[String]> = reorderable_layouts.into();