Files
OneCAD-Draft/src/mcp.rs
T
Karim Jerbi be8a7b95c4 fix(io): cap line reads on the serve and MCP transports
Bug:
The --serve automation loop (stdin/socket) and the MCP stdio loop read
input with unbounded read_line/lines. A single line over 64 MiB - a
huge paste or a hostile client - grows the buffer until the allocator
aborts, the same class of failure as the HTTP path, on the non-HTTP
transports.

Reproduce:
1. Start --serve on a socket (or the MCP server on stdio).
2. Send one 65 MiB line with no terminating newline anywhere in it.
3. Before: RSS grows to the full line, then "memory allocation failed"
   (or a multi-second stall while the line is copied).
4. After: the read fails at the cap and the existing EOF arm closes the
   connection/stdio promptly.

Fix:
New shared src/io/line_read.rs module: lines_capped(reader, 64 MiB)
yields Err for an over-cap line, which the serve and MCP loops already
treat as EOF/close. rest.rs was refactored onto the same helpers so the
cap logic lives in one place. A regression test streams a 65 MiB line
at the serve socket and asserts the connection ends without a response.
2026-09-30 19:15:49 +01:00

1595 lines
64 KiB
Rust

//! 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 as _, engine::general_purpose::STANDARD as BASE64};
use serde::Deserialize;
use serde_json::{Map, Value, json};
use std::{
collections::{HashMap, VecDeque},
fs::{File, OpenOptions},
io::{self, 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 CACHE_TTL_MS: u64 = 3_600_000;
const INSTRUCTIONS: &str = "Call ocs_sessions, then pass its session_id as ocs_session_id to ocs_read, ocs_execute and ocs_capture. Read capabilities to discover the complete CAD automation surface. Call record_schema to discover every record type, property path, JSON type, enum, unit, constraint and write rule before editing unfamiliar data. Use records to inspect every serializable entity, object, table, header and document record; filter with RFC 6901 JSON Pointer paths. Use set_properties for atomic, type-checked record edits and preserve document_id, revision and request_id. Use commands with parameters.name for a command manifest. Use batch when several steps are known, and request changed_entities when resulting geometry is needed. For interactive work, call start and follow state.command.accepts, options and input_example. To have the person at the screen pick entities for you, call user_select and keep polling until it completes; running means they are still picking. A run.cmd contains the command name followed by prompt answers separated by spaces; points use x,y or x,y,z. 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 OCS and its geometry kernel calculate geometry; use query near, contains_point and intersections for exact relationships. Verify important results with queries and a viewport capture, and save only to an explicit path. Before delivery call audit with the intended target_format and target_version; use save_verified with an explicit absolute path to save, reopen, hash and compare the semantic manifest.";
const READ_OPS: &[&str] = &[
"state",
"hello",
"query",
"records",
"record_schema",
"capabilities",
"entities",
"layers",
"header",
"properties",
"measure",
"snap",
"history",
"commands",
"events",
"operation",
"xdata_get",
"audit",
"text_search",
"text_audit",
];
const EXECUTE_OPS: &[&str] = &[
"new",
"open",
"activate",
"run",
"start",
"input",
"cancel",
"undo",
"redo",
"select",
"property",
"set_properties",
"action",
"embed_image",
"wblock",
"plot",
"entities_create",
"entities_delete",
"entities_transform",
"text_replace",
"block_define",
"block_delete",
"xdata_set",
"view_focus",
"entities_copy_to",
"group_create",
"selection_set_save",
"selection_set_load",
"user_select",
"getpoint",
"close",
"sysvar",
"layout_create",
"page_setup_set",
"file_identity",
"save",
"save_verified",
"stop",
"batch",
];
const BATCH_STEP_OPS: &[&str] = &[
"new",
"open",
"activate",
"run",
"start",
"input",
"cancel",
"undo",
"redo",
"select",
"property",
"set_properties",
"action",
"embed_image",
"wblock",
"plot",
"entities_create",
"entities_delete",
"entities_transform",
"text_replace",
"block_define",
"block_delete",
"xdata_set",
"view_focus",
"entities_copy_to",
"group_create",
"selection_set_save",
"selection_set_load",
"user_select",
"getpoint",
"close",
"sysvar",
"layout_create",
"page_setup_set",
"file_identity",
"save",
"stop",
];
const MAX_BATCH_STEPS: usize = 64;
#[derive(Clone, Deserialize)]
struct Descriptor {
session_id: String,
port: u16,
token: String,
}
struct GuiClient {
descriptor: Descriptor,
state: Value,
client_id: String,
batches: VecDeque<BatchExecution>,
}
struct BatchExecution {
id: String,
request: Value,
steps: Vec<Value>,
next: usize,
active: Option<String>,
results: Vec<Value>,
changes: Vec<Value>,
state: Option<Value>,
terminal: Option<Value>,
}
struct McpTask {
id: String,
name: String,
arguments: Value,
created_at: String,
last_updated_at: String,
result: Option<Value>,
error: Option<Value>,
}
#[derive(Default)]
struct TaskStore {
tasks: VecDeque<McpTask>,
}
impl TaskStore {
fn insert(&mut self, task: McpTask) {
self.tasks.push_back(task);
}
fn get_mut(&mut self, id: &str) -> Option<&mut McpTask> {
self.tasks.iter_mut().find(|task| task.id == id)
}
}
fn random_id() -> Result<String, String> {
let mut bytes = [0u8; 16];
getrandom::fill(&mut bytes).map_err(|error| error.to_string())?;
Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
}
fn iso8601_now() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let days = seconds.div_euclid(86_400);
let day_seconds = seconds.rem_euclid(86_400);
let shifted = days + 719_468;
let era = shifted.div_euclid(146_097);
let day_of_era = shifted - era * 146_097;
let year_of_era =
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let mut year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_prime = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
year += i64::from(month <= 2);
let hour = day_seconds / 3_600;
let minute = day_seconds % 3_600 / 60;
let second = day_seconds % 60;
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}
#[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<Value, String> {
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<Vec<(Descriptor, Value)>, 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<PathBuf> = 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::<Descriptor>(&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<File, String> {
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<Child, String> {
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<Vec<Value>, 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<String, Value>, key: &str, value: Value) {
if !object.contains_key(key) {
object.insert(key.into(), value);
}
}
impl GuiClient {
fn connect(session_id: &str) -> Result<Self, String> {
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()?,
batches: VecDeque::new(),
})
}
fn request(&mut self, request: Value, wait_seconds: f64) -> Result<Value, String> {
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()),
);
if op != "entities_copy_to" {
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", "save_verified", "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 execute_batch(&mut self, request: Value, wait_seconds: f64) -> Result<Value, String> {
let id = required_string(&request, "request_id")?.to_owned();
let mut batch = if let Some(position) = self.batches.iter().position(|batch| batch.id == id)
{
let batch = self
.batches
.remove(position)
.expect("batch position exists");
if batch.request != request {
self.batches.push_back(batch);
return Err("request_id was already used for a different batch".into());
}
batch
} else {
let steps = request["steps"]
.as_array()
.cloned()
.ok_or_else(|| "batch requires a steps array".to_string())?;
BatchExecution {
id,
request,
steps,
next: 0,
active: None,
results: Vec::new(),
changes: Vec::new(),
state: None,
terminal: None,
}
};
if let Some(result) = batch.terminal.clone() {
self.batches.push_back(batch);
return Ok(result);
}
let deadline = Instant::now() + Duration::from_secs_f64(wait_seconds.clamp(0.0, 60.0));
let mut attempted = false;
loop {
if batch.next == batch.steps.len() {
let waiting = batch
.state
.as_ref()
.is_some_and(|state| !state["command"].is_null());
let result = batch_result(
&batch,
if waiting {
"waiting_input"
} else {
"completed"
},
true,
);
batch.terminal = Some(result.clone());
self.batches.push_back(batch);
trim_batches(&mut self.batches);
return Ok(result);
}
if attempted && Instant::now() >= deadline {
let result = batch_result(&batch, "running", true);
self.batches.push_back(batch);
trim_batches(&mut self.batches);
return Ok(result);
}
attempted = true;
let step_id = batch
.active
.clone()
.unwrap_or_else(|| batch_step_id(&batch.id, batch.next));
let response = if batch.active.is_some() {
self.request(
json!({"op":"operation","request_id":step_id}),
deadline
.saturating_duration_since(Instant::now())
.as_secs_f64(),
)
} else {
let mut step = batch.steps[batch.next]
.as_object()
.cloned()
.ok_or_else(|| format!("batch step {} must be an object", batch.next))?;
for key in [
"revision",
"geometry_revision",
"camera_revision",
"selection",
"client_id",
] {
step.remove(key);
}
step.insert("request_id".into(), Value::String(step_id.clone()));
self.request(
Value::Object(step),
deadline
.saturating_duration_since(Instant::now())
.as_secs_f64(),
)
};
let response = match response {
Ok(response) => response,
Err(error) => {
batch.active = Some(step_id);
self.batches.push_back(batch);
trim_batches(&mut self.batches);
return Err(error);
}
};
if matches!(response["status"].as_str(), Some("accepted" | "running")) {
batch.active = Some(step_id);
let result = batch_result(&batch, "running", true);
self.batches.push_back(batch);
trim_batches(&mut self.batches);
return Ok(result);
}
batch.active = None;
if let Some(state) = response.get("state") {
batch.state = Some(state.clone());
}
if let Some(changes) = response["changes"].as_array() {
batch
.changes
.extend(changes.iter().cloned().map(|mut change| {
if let Some(object) = change.as_object_mut() {
object.insert("step".into(), Value::from(batch.next));
}
change
}));
}
let mut compact = response.clone();
if let Some(object) = compact.as_object_mut() {
object.remove("state");
object.remove("changes");
object.insert("step".into(), Value::from(batch.next));
object.insert("op".into(), batch.steps[batch.next]["op"].clone());
}
batch.results.push(compact);
batch.next += 1;
if response["ok"].as_bool() == Some(false)
|| matches!(response["status"].as_str(), Some("failed" | "cancelled"))
{
let result = batch_result(
&batch,
response["status"].as_str().unwrap_or("failed"),
false,
);
batch.terminal = Some(result.clone());
self.batches.push_back(batch);
trim_batches(&mut self.batches);
return Ok(result);
}
if response["status"] == "waiting_input"
&& batch
.steps
.get(batch.next)
.and_then(|step| step["op"].as_str())
.is_none_or(|op| !matches!(op, "input" | "cancel"))
{
let result = batch_result(&batch, "waiting_input", true);
batch.terminal = Some(result.clone());
self.batches.push_back(batch);
trim_batches(&mut self.batches);
return Ok(result);
}
}
}
}
fn batch_step_id(id: &str, step: usize) -> String {
let hash = id
.as_bytes()
.iter()
.fold(0xcbf29ce484222325u64, |hash, byte| {
(hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3)
});
format!("batch-{hash:016x}-{step}")
}
fn batch_result(batch: &BatchExecution, status: &str, ok: bool) -> Value {
json!({
"ok":ok,
"status":status,
"request_id":batch.id,
"completed_steps":batch.next,
"total_steps":batch.steps.len(),
"next_step":(batch.next < batch.steps.len()).then_some(batch.next),
"results":batch.results,
"changes":batch.changes,
"state":batch.state
})
}
fn trim_batches(batches: &mut VecDeque<BatchExecution>) {
while batches.len() > 64 {
batches.pop_front();
}
}
fn client<'a>(
clients: &'a mut HashMap<String, GuiClient>,
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 validate_execute_request(
request: &Value,
op: &str,
) -> Result<crate::mcp_ops::ValidationResult, String> {
if op == "batch" {
let steps = request["steps"].as_array().ok_or_else(|| {
r#"Missing steps for batch. Example request: {"op":"batch","request_id":"draw-1","steps":[{"op":"run","cmd":"LINE 0,0 10,0"}]}"#.to_string()
})?;
if steps.is_empty() || steps.len() > MAX_BATCH_STEPS {
return Err(format!(
"batch steps must contain 1 to {MAX_BATCH_STEPS} operations"
));
}
let mut warnings = Vec::new();
for (index, step) in steps.iter().enumerate() {
let step_obj = step
.as_object()
.ok_or_else(|| format!("batch step {index} must be an object"))?;
if step_obj.contains_key("request_id") {
return Err(format!(
"batch step {index} must omit request_id; the batch assigns idempotency keys"
));
}
let step_op = step_obj
.get("op")
.and_then(Value::as_str)
.ok_or_else(|| format!("batch step {index} is missing op"))?;
if !BATCH_STEP_OPS.contains(&step_op) {
return Err(format!("Unknown operation {step_op} in batch step {index}"));
}
let step_val = crate::mcp_ops::validate_request(step, true)
.map_err(|error| format!("batch step {index}: {error}"))?;
for w in step_val.warnings {
warnings.push(format!("batch step {index}: {w}"));
}
}
return Ok(crate::mcp_ops::ValidationResult { warnings });
}
crate::mcp_ops::validate_request(request, false)
}
fn compact_state(state: &Value) -> Value {
let mut compact = Map::new();
for key in [
"session_id",
"document_id",
"revision",
"geometry_revision",
"camera_revision",
"selection",
"command",
"modal",
"event_cursor",
"operation",
] {
if let Some(value) = state.get(key) {
compact.insert(key.into(), value.clone());
}
}
Value::Object(compact)
}
fn response_handles(response: &Value) -> Vec<String> {
let mut handles = Vec::new();
if let Some(changes) = response["changes"].as_array() {
for handle in changes
.iter()
.filter_map(|change| change["handle"].as_str())
{
if !handles.iter().any(|value| value == handle) {
handles.push(handle.to_owned());
}
}
}
handles
}
fn shape_execute_response(
mut response: Value,
detail: &str,
gui: &mut GuiClient,
) -> Result<Value, String> {
if detail == "changed_entities" {
let handles = response_handles(&response);
if !handles.is_empty() {
let entities = gui.request(
json!({"op":"query","handles":handles,"detail":"geometry","limit":MAX_BATCH_STEPS * 100}),
30.0,
)?;
if let Some(object) = response.as_object_mut() {
object.insert("changed_entities".into(), entities["entities"].clone());
}
}
}
if detail != "full" {
if let Some(state) = response.get("state").cloned() {
if let Some(object) = response.as_object_mut() {
object.insert("state".into(), compact_state(&state));
}
}
}
Ok(response)
}
fn call_tool(
name: &str,
arguments: &Value,
clients: &mut HashMap<String, GuiClient>,
) -> Result<Value, String> {
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, "ocs_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, "ocs_session_id")?;
let request = arguments["request"]
.as_object()
.cloned()
.map(Value::Object)
.ok_or_else(|| "Missing request object".to_string())?;
let op = required_string(&request, "op")?;
if !EXECUTE_OPS.contains(&op) {
return Err(format!("Unknown mutation operation: {op}"));
}
let request_id = required_string(&request, "request_id")?;
if request_id.len() > 128 {
return Err("request_id must not exceed 128 bytes".into());
}
let val_res = validate_execute_request(&request, op)?;
let wait = arguments["wait_seconds"].as_f64().unwrap_or(30.0);
let detail = arguments["response_detail"].as_str().unwrap_or("compact");
let gui = client(clients, session_id)?;
let mut response = if op == "batch" {
gui.execute_batch(request, wait)?
} else {
gui.request(request, wait)?
};
if !val_res.warnings.is_empty() {
if let Some(object) = response.as_object_mut() {
object.insert(
"warnings".into(),
Value::Array(val_res.warnings.into_iter().map(Value::String).collect()),
);
}
}
shape_execute_response(response, detail, gui)
}
"ocs_capture" => {
let session_id = required_string(arguments, "ocs_session_id")?;
let path = std::env::temp_dir().join(format!("ocs-capture-{}.png", random_id()?));
let scope = arguments["scope"].as_str().unwrap_or("viewport");
let max_dimension = arguments["max_dimension"].as_u64().unwrap_or(1600);
let result = client(clients, session_id)?
.request(json!({"op":"capture","path":path.to_string_lossy(),"scope":scope,"max_dimension":max_dimension}), 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}")),
}
}
#[allow(dead_code)]
fn batch_step_schema() -> Value {
crate::mcp_ops::batch_step_schema()
}
fn execute_request_schema() -> Value {
crate::mcp_ops::execute_request_schema()
}
fn read_output_schema() -> Value {
json!({
"type":"object",
"properties":{
"ok":{"type":"boolean"},"status":{"type":"string"},"code":{"type":"string"},
"error":{"anyOf":[{"type":"string"},{"type":"null"}]},"document_id":{"type":"integer"},
"revision":{"type":"integer"},"geometry_revision":{"type":"integer"},
"camera_revision":{"type":"integer"}
},
"required":["ok"],"additionalProperties":true
})
}
fn execute_output_schema() -> Value {
json!({
"type":"object",
"properties":{
"ok":{"type":"boolean"},
"status":{"type":"string","enum":["accepted","running","waiting_input","completed","cancelled","failed"]},
"request_id":{"type":"string"},"code":{"type":"string"},"error":{"anyOf":[{"type":"string"},{"type":"null"}]},
"result":{"type":"object"},"changes":{"anyOf":[{"type":"array"},{"type":"null"}]},"state":{"type":"object"}
},
"required":["ok"],"additionalProperties":true
})
}
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,"description":"Launch OpenCADStudio when no live session exists."}},"additionalProperties":false},
"outputSchema":{"type":"object","properties":{"result":{"type":"array","items":{"type":"object","properties":{"ok":{"const":true},"session_id":{"type":"string"},"document_id":{"type":"integer"},"revision":{"type":"integer"},"selection":{"type":"array","items":{"type":"string"}},"documents":{"type":"array"}},"required":["ok","session_id","document_id","revision","selection","documents"],"additionalProperties":true}}},"required":["result"],"additionalProperties":false},
"annotations":{"title":"List OCS sessions","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}
},
{
"name":"ocs_read",
"description":"Discover capabilities and record schemas, or read state, complete database records, command manifests, entities, properties, kernel measurements and spatial relationships, history, events or operation status from a live OCS session.",
"inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name or record name."},"search":{"type":"string","description":"Case-insensitive command or record-type search."},"find":{"type":"string","description":"Text query string for text_search."},"match_case":{"type":"boolean","default":false,"description":"Case-sensitive text search."},"whole_word":{"type":"boolean","default":false,"description":"Match only whole words."},"ignore_accents":{"type":"boolean","description":"Ignore accents/diacritics in text (defaults to true when match_case is false)."},"scope":{"type":"string","enum":["all","active_space","blocks"],"default":"all","description":"Text search scope."},"system_spellcheck":{"type":"boolean","default":false,"description":"Use native OS spell-checker (Windows, macOS, Linux)."},"language":{"type":"string","description":"Language tag for spell-checker (e.g. 'fr-FR', 'en-US', 'de-DE', 'es-ES')."},"suggest":{"type":"boolean","default":true,"description":"Include suggested corrections for misspelled words."},"check_terms":{"type":"array","items":{"type":"string"},"description":"List of terms or suspect misspellings to flag in text_audit."},"dictionary":{"type":"array","items":{"type":"string"},"description":"Known valid words for text_audit dictionary check."},"pairs":{"type":"array","description":"Find/replace pairs for dry-run simulation in text_audit.","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]}},"dry_run_pairs":{"type":"array","description":"Alias for pairs in text_audit.","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]}},"document_id":{"type":"integer","minimum":0},"path":{"type":"string","description":"Optional intended output path for audit; extension determines target format."},"target_format":{"type":"string","enum":["dwg","dxf"],"description":"Intended output format for audit."},"target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"],"description":"Intended CAD output version for audit."},"collection":{"type":"string","description":"Record collection, all for records, or omit to discover collections and schema types."},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity or record handles."},"type":{"type":"string","description":"Entity or record type filter; for record_schema, returns its complete type graph and writable field paths."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"paths":{"type":"array","items":{"type":"string"},"description":"Project RFC 6901 JSON Pointer paths relative to record.properties."},"where":{"type":"array","description":"All property filters must match.","items":{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string","enum":["eq","ne","lt","lte","gt","gte","contains","starts_with","ends_with","in","exists","not_exists"],"default":"eq"},"value":{}},"required":["path"],"additionalProperties":false}},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"World point whose object snap the snap op reports."},"from":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Base point for perpendicular and tangent snaps (snap op)."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":10000}},"additionalProperties":false}},"required":["ocs_session_id"],"additionalProperties":false},
"outputSchema":read_output_schema(),
"annotations":{"title":"Read OCS state","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}
},
{
"name":"ocs_execute",
"description":"Execute semantic OCS actions. Use current state fields and a unique request_id. Use run for one complete command, batch to remove round trips, or start plus input for guided steps. user_select asks the person at the screen to pick entities and stays running until they press Enter (answer with entities) or Escape (cancelled). accepted, running and waiting_input are not completion.",
"inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"request":execute_request_schema(),"wait_seconds":{"type":"number","minimum":0,"maximum":60,"default":30,"description":"Total time to wait for completion before returning."},"response_detail":{"type":"string","enum":["compact","changed_entities","full"],"default":"compact","description":"compact returns only state needed for the next edit; changed_entities also returns current geometry for changed handles; full preserves the complete editor state."}},"required":["ocs_session_id","request"],"additionalProperties":false},
"outputSchema":execute_output_schema(),
"annotations":{"title":"Execute OCS action","readOnlyHint":false,"destructiveHint":true,"idempotentHint":true,"openWorldHint":false}
},
{
"name":"ocs_capture",
"description":"Capture the actual current OCS drawing viewport or window as a bounded PNG for visual verification.",
"inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"scope":{"type":"string","enum":["viewport","window"],"default":"viewport","description":"Capture only the drawing viewport by default, or the complete application window."},"max_dimension":{"type":"integer","minimum":256,"maximum":4096,"default":1600,"description":"Resize the longest image edge to at most this many pixels."}},"required":["ocs_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,
"isError":value["ok"].as_bool() == Some(false)
})
}
fn error_result(message: impl ToString) -> Value {
let message = message.to_string();
json!({
"content":[{"type":"text","text":message.clone()}],
"structuredContent":{"ok":false,"status":"failed","code":"invalid_arguments","error":message,"retryable":false},
"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!("OCS_APP_VERSION")})
}
fn modern_request(params: &Value) -> bool {
params["_meta"]["io.modelcontextprotocol/protocolVersion"].as_str()
== Some(MODERN_PROTOCOL_VERSION)
}
fn supports_tasks(params: &Value) -> bool {
params["_meta"]["io.modelcontextprotocol/clientCapabilities"]["extensions"]
["io.modelcontextprotocol/tasks"]
.is_object()
}
fn task_value(task: &McpTask, status: &str) -> Value {
let mut value = json!({
"resultType":"complete",
"taskId":task.id,
"status":status,
"createdAt":task.created_at,
"lastUpdatedAt":task.last_updated_at,
"ttlMs":3_600_000,
"pollIntervalMs":250
});
if let Some(result) = &task.result {
value["result"] = result.clone();
}
if let Some(error) = &task.error {
value["error"] = error.clone();
}
value
}
fn poll_task(task: &mut McpTask, clients: &mut HashMap<String, GuiClient>) -> Value {
if task.result.is_some() {
return task_value(task, "completed");
}
if task.error.is_some() {
return task_value(task, "failed");
}
task.last_updated_at = iso8601_now();
let mut arguments = task.arguments.clone();
arguments["wait_seconds"] = Value::from(0);
match call_tool(&task.name, &arguments, clients) {
Ok(value) if matches!(value["status"].as_str(), Some("accepted" | "running")) => {
task_value(task, "working")
}
Ok(value) => {
task.result = Some(tool_result(value));
task_value(task, "completed")
}
Err(error) => {
task.error = Some(json!({"code":-32000,"message":error}));
task_value(task, "failed")
}
}
}
fn protocol_result(mut result: Value, modern: bool, cacheable: 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()}),
);
if cacheable {
object.insert("ttlMs".into(), Value::from(CACHE_TTL_MS));
object.insert("cacheScope".into(), Value::String("public".into()));
}
}
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 unsupported_protocol(id: Value, requested: &str) -> Value {
json!({
"jsonrpc":"2.0",
"id":id,
"error":{
"code":-32022,
"message":format!("Unsupported protocol version: {requested}"),
"data":{"requested":requested,"supported":[MODERN_PROTOCOL_VERSION,PROTOCOL_VERSION]}
}
})
}
fn handle_message(
message: Value,
clients: &mut HashMap<String, GuiClient>,
tasks: &mut TaskStore,
) -> Option<Value> {
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!({}));
if let Some(requested) = params["_meta"]["io.modelcontextprotocol/protocolVersion"].as_str() {
if requested != MODERN_PROTOCOL_VERSION {
return Some(unsupported_protocol(id, requested));
}
}
let modern = modern_request(&params);
Some(match method {
"initialize" if modern => rpc_error(id, -32601, "Method not found: initialize"),
"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":{},"extensions":{"io.modelcontextprotocol/tasks":{}}},
"instructions":INSTRUCTIONS
}),
true,
true,
),
),
"ping" => response(id, protocol_result(json!({}), modern, false)),
"tools/list" => response(
id,
protocol_result(json!({"tools":tool_definitions()}), modern, true),
),
"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 called = call_tool(name, &arguments, clients);
if modern && supports_tasks(&params) {
if let Ok(value) = &called {
if matches!(value["status"].as_str(), Some("accepted" | "running")) {
let task_id =
random_id().unwrap_or_else(|_| format!("task-{}", iso8601_now()));
let now = iso8601_now();
tasks.insert(McpTask {
id: task_id.clone(),
name: name.to_owned(),
arguments,
created_at: now.clone(),
last_updated_at: now,
result: None,
error: None,
});
return Some(response(
id,
protocol_result(
json!({
"resultType":"task",
"taskId":task_id,
"status":"working",
"statusMessage":"OCS operation is running.",
"createdAt":tasks.tasks.back().unwrap().created_at,
"lastUpdatedAt":tasks.tasks.back().unwrap().last_updated_at,
"ttlMs":3_600_000,
"pollIntervalMs":250
}),
true,
false,
),
));
}
}
}
let result = called.map(tool_result).unwrap_or_else(error_result);
response(id, protocol_result(result, modern, false))
}
"tasks/get" if modern && supports_tasks(&params) => {
let Some(task_id) = params["taskId"].as_str() else {
return Some(rpc_error(id, -32602, "Missing taskId"));
};
let Some(task) = tasks.get_mut(task_id) else {
return Some(rpc_error(id, -32602, "Unknown or expired taskId"));
};
response(id, protocol_result(poll_task(task, clients), true, false))
}
"tasks/update" if modern && supports_tasks(&params) => {
let Some(task_id) = params["taskId"].as_str() else {
return Some(rpc_error(id, -32602, "Missing taskId"));
};
if tasks.get_mut(task_id).is_none() {
return Some(rpc_error(id, -32602, "Unknown or expired taskId"));
}
response(id, protocol_result(json!({}), true, false))
}
"tasks/cancel" if modern && supports_tasks(&params) => {
let Some(task_id) = params["taskId"].as_str() else {
return Some(rpc_error(id, -32602, "Missing taskId"));
};
if tasks.get_mut(task_id).is_none() {
return Some(rpc_error(id, -32602, "Unknown or expired taskId"));
}
response(id, protocol_result(json!({}), true, false))
}
_ => 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();
let mut tasks = TaskStore::default();
for line in crate::io::line_read::lines_capped(
stdin.lock(),
crate::io::line_read::MAX_LINE_BYTES,
) {
let response = match line {
Ok(line) if !line.trim().is_empty() => match serde_json::from_str::<Value>(&line) {
Ok(message) => handle_message(message, &mut clients, &mut tasks),
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"]
);
assert_eq!(tools[0]["annotations"]["readOnlyHint"], false);
for tool in [&tools[1], &tools[2], &tools[3]] {
assert!(
tool["inputSchema"]["properties"]
.get("session_id")
.is_none()
);
assert!(
tool["inputSchema"]["required"]
.as_array()
.unwrap()
.contains(&json!("ocs_session_id"))
);
}
assert_eq!(
tools[2]["inputSchema"]["properties"]["request"]["required"],
json!(["op", "request_id"])
);
let request_variants = tools[2]["inputSchema"]["properties"]["request"]["anyOf"]
.as_array()
.unwrap();
assert_eq!(request_variants.len(), EXECUTE_OPS.len());
assert_eq!(
tools[2]["inputSchema"]["properties"]["request"]["properties"]["steps"]["maxItems"],
MAX_BATCH_STEPS
);
assert_eq!(
tools[2]["inputSchema"]["properties"]["response_detail"]["default"],
"compact"
);
assert_eq!(
tools[3]["inputSchema"]["properties"]["scope"]["default"],
"viewport"
);
let find_variant = |op_name: &str| {
request_variants
.iter()
.find(|v| v["properties"]["op"]["enum"][0] == op_name)
.cloned()
.unwrap_or_else(|| panic!("variant for {op_name} exists"))
};
let run_var = find_variant("run");
assert_eq!(run_var["properties"]["cmd"]["type"], "string");
assert!(READ_OPS.contains(&"capabilities"));
assert!(READ_OPS.contains(&"records"));
assert!(READ_OPS.contains(&"record_schema"));
assert!(READ_OPS.contains(&"audit"));
assert!(READ_OPS.contains(&"text_search"));
assert!(READ_OPS.contains(&"text_audit"));
assert!(EXECUTE_OPS.contains(&"set_properties"));
assert!(EXECUTE_OPS.contains(&"save_verified"));
assert!(EXECUTE_OPS.contains(&"text_replace"));
assert!(BATCH_STEP_OPS.contains(&"text_replace"));
let save_var = find_variant("save_verified");
assert_eq!(
save_var["properties"]["target_version"]["enum"][0],
"R14"
);
assert_eq!(
tools[1]["inputSchema"]["properties"]["parameters"]["properties"]["where"]["items"]["properties"]
["op"]["enum"],
json!([
"eq",
"ne",
"lt",
"lte",
"gt",
"gte",
"contains",
"starts_with",
"ends_with",
"in",
"exists",
"not_exists"
])
);
let set_props_var = find_variant("set_properties");
assert_eq!(
set_props_var["properties"]["updates"]["items"]["required"],
json!(["path", "value"])
);
assert!(tools[0].get("outputSchema").is_some());
assert!(tools[1].get("outputSchema").is_some());
assert!(tools[2].get("outputSchema").is_some());
}
#[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,
&mut TaskStore::default(),
)
.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,
&mut TaskStore::default(),
)
.unwrap();
assert_eq!(listed["result"]["tools"].as_array().unwrap().len(), 4);
assert!(listed["result"].get("ttlMs").is_none());
assert!(listed["result"].get("resultType").is_none());
}
#[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,
&mut TaskStore::default(),
)
.unwrap();
assert_eq!(discovered["result"]["resultType"], "complete");
assert_eq!(discovered["result"]["ttlMs"], CACHE_TTL_MS);
assert_eq!(discovered["result"]["cacheScope"], "public");
assert_eq!(discovered["result"]["supportedVersions"][0], "2026-07-28");
assert!(
discovered["result"]["capabilities"]["extensions"]["io.modelcontextprotocol/tasks"]
.is_object()
);
assert_eq!(
discovered["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"],
"OpenCADStudio"
);
let listed = handle_message(
json!({"jsonrpc":"2.0","id":"tools","method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}),
&mut clients,
&mut TaskStore::default(),
)
.unwrap();
assert_eq!(listed["result"]["resultType"], "complete");
assert_eq!(listed["result"]["ttlMs"], CACHE_TTL_MS);
assert_eq!(listed["result"]["cacheScope"], "public");
}
#[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":{"ocs_session_id":"missing","op":"save"}}}),
&mut clients,
&mut TaskStore::default(),
)
.unwrap();
assert_eq!(called["result"]["isError"], true);
}
#[test]
fn rejects_unknown_modern_protocol_versions() {
let mut clients = HashMap::new();
let rejected = handle_message(
json!({"jsonrpc":"2.0","id":4,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2099-01-01"}}}),
&mut clients,
&mut TaskStore::default(),
)
.unwrap();
assert_eq!(rejected["error"]["code"], -32022);
assert_eq!(rejected["error"]["data"]["requested"], "2099-01-01");
assert_eq!(
rejected["error"]["data"]["supported"][0],
MODERN_PROTOCOL_VERSION
);
}
#[test]
fn execute_requires_a_visible_request_id() {
let mut clients = HashMap::new();
let called = handle_message(
json!({"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"ocs_execute","arguments":{"ocs_session_id":"missing","request":{"op":"undo"}}}}),
&mut clients,
&mut TaskStore::default(),
)
.unwrap();
assert_eq!(called["result"]["isError"], true);
assert!(
called["result"]["structuredContent"]["error"]
.as_str()
.unwrap()
.contains("request_id")
);
}
#[test]
fn execute_errors_explain_missing_operation_fields() {
let mut clients = HashMap::new();
let called = handle_message(
json!({"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"ocs_execute","arguments":{"ocs_session_id":"missing","request":{"op":"run","request_id":"run-1"}}}}),
&mut clients,
&mut TaskStore::default(),
)
.unwrap();
assert_eq!(called["result"]["isError"], true);
assert_eq!(
called["result"]["structuredContent"]["code"],
"invalid_arguments"
);
let error = called["result"]["structuredContent"]["error"]
.as_str()
.unwrap();
assert!(error.contains("Missing cmd"), "{error}");
assert!(error.contains("LINE 0,0 10,10"), "{error}");
}
#[test]
fn validates_batch_steps_and_compacts_state() {
let batch = json!({
"op":"batch",
"request_id":"draw",
"steps":[{"op":"run","cmd":"LINE 0,0 10,0"},{"op":"run","cmd":"CIRCLE 5,5 2"}]
});
assert!(validate_execute_request(&batch, "batch").is_ok());
let invalid = json!({
"op":"batch",
"request_id":"draw",
"steps":[{"op":"run","request_id":"nested","cmd":"LINE 0,0 10,0"}]
});
assert!(
validate_execute_request(&invalid, "batch")
.unwrap_err()
.contains("omit request_id")
);
let compact = compact_state(&json!({
"session_id":"s","document_id":3,"revision":4,"geometry_revision":5,
"camera_revision":6,"selection":[],"command":null,"documents":[1,2,3],
"camera":{"distance":100.0},"event_cursor":7
}));
assert_eq!(compact["revision"], 4);
assert!(compact.get("camera").is_none());
assert!(compact.get("documents").is_none());
}
#[test]
fn task_metadata_uses_the_modern_shape() {
let now = iso8601_now();
assert_eq!(now.len(), 20);
assert!(now.ends_with('Z'));
let task = McpTask {
id: "task".into(),
name: "ocs_execute".into(),
arguments: json!({}),
created_at: now.clone(),
last_updated_at: now,
result: None,
error: None,
};
let value = task_value(&task, "working");
assert_eq!(value["resultType"], "complete");
assert_eq!(value["status"], "working");
assert_eq!(value["pollIntervalMs"], 250);
}
#[test]
fn gui_failures_are_mcp_errors() {
let result = tool_result(json!({
"ok":false,
"status":"failed",
"code":"stale_state",
"error":"Refresh state before editing"
}));
assert_eq!(result["isError"], true);
assert_eq!(result["structuredContent"]["code"], "stale_state");
}
#[test]
fn all_op_examples_pass_pre_dispatch_validation() {
for op in crate::mcp_ops::OPS {
let mut req: Value = serde_json::from_str(op.example)
.unwrap_or_else(|e| panic!("invalid json example for {}: {e}", op.name));
if let Some(obj) = req.as_object_mut() {
obj.insert("request_id".into(), Value::String("example-id".into()));
}
let res = crate::mcp_ops::validate_request(&req, false);
assert!(
res.is_ok(),
"example for op '{}' failed validation: {:?}",
op.name,
res.err()
);
if op.batchable {
let mut batch_req: Value = serde_json::from_str(op.example).unwrap();
if let Some(obj) = batch_req.as_object_mut() {
obj.remove("request_id");
}
let b_res = crate::mcp_ops::validate_request(&batch_req, true);
assert!(
b_res.is_ok(),
"batch example for op '{}' failed validation: {:?}",
op.name,
b_res.err()
);
}
}
}
#[test]
fn pre_dispatch_validator_catches_unknown_parameters_as_warnings() {
let req = json!({
"op": "run",
"request_id": "r1",
"cmd": "LINE 0,0 10,0",
"extra_param": 123
});
let res = crate::mcp_ops::validate_request(&req, false).expect("validation passes");
assert_eq!(res.warnings.len(), 1);
assert!(res.warnings[0].contains("unknown parameter 'extra_param' ignored for op 'run'"));
// Batch step warnings propagation
let batch = json!({
"op": "batch",
"request_id": "b1",
"steps": [
{"op": "run", "cmd": "LINE 0,0 10,0", "stray": true}
]
});
let batch_res = validate_execute_request(&batch, "batch").expect("batch passes");
assert_eq!(batch_res.warnings.len(), 1);
assert!(batch_res.warnings[0].contains("batch step 0: unknown parameter 'stray' ignored for op 'run'"));
}
#[test]
fn pre_dispatch_validator_enforces_required_fields_and_rules() {
// Missing required field
let missing_cmd = json!({"op": "run", "request_id": "r1"});
let err = crate::mcp_ops::validate_request(&missing_cmd, false).unwrap_err();
assert!(err.contains("Missing cmd for run. Example request:"), "{err}");
// Required-when rule (scale requires factor)
let scale_missing_factor = json!({
"op": "entities_transform",
"request_id": "r2",
"handles": ["2A"],
"action": "scale"
});
let err2 = crate::mcp_ops::validate_request(&scale_missing_factor, false).unwrap_err();
assert!(err2.contains("Missing factor for entities_transform. Example request:"), "{err2}");
// Hex handle enforcement
let invalid_handle = json!({
"op": "entities_delete",
"request_id": "r3",
"handles": ["NOT_A_HEX_HANDLE!"]
});
let err3 = crate::mcp_ops::validate_request(&invalid_handle, false).unwrap_err();
assert!(err3.contains("hexadecimal handle strings"), "{err3}");
// Non-batchable op rejected in batch
let stop_in_batch = json!({"op": "stop"});
let err4 = crate::mcp_ops::validate_request(&stop_in_batch, true).unwrap_err();
assert!(err4.contains("operation 'stop' cannot be used in a batch step"), "{err4}");
}
#[test]
fn schema_size_and_defs_structure() {
let schema = execute_request_schema();
assert_eq!(schema["$schema"], "https://json-schema.org/draft/2020-12/schema");
assert!(schema["$defs"]["handle"].is_object());
assert!(schema["$defs"]["point"].is_object());
assert!(schema["$defs"]["window"].is_object());
assert!(schema["$defs"]["batch_step"].is_object());
let json_str = schema.to_string();
// Generates cleanly without exponential blowup: compact ~15KB to 30KB
assert!(json_str.len() < 40_000, "schema size is {} bytes", json_str.len());
}
}