Files
OneCAD-Draft/src/mcp.rs
T
ramox81andClaude Opus 5.5 edbbc9d135 PDF follow-ups: import layers, arcs and joins, names, background, page placement, snaps
- Unlayered raster images go with the PDF layer drawn last before them.
- Circular Bezier arcs import as arcs: bulged polyline segments with Join,
  ARC entities without it; straight chains still join.
- Imported PNGs go to "PDF Images" next to the PDF; import blocks are
  numbered imp31, imp32, ...
- Background darkness is its HSL lightness below one half.
- All chosen pages attach at the same insertion point.
- PDF geometry snaps as separate pieces: centre from the rim, intersections
  and perpendiculars.
- Automation: snap query with an optional base point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:33:48 +03:00

1797 lines
80 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, 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 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",
];
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",
"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",
"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()),
);
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<(), String> {
let missing = |field: &str, example: &str| {
Err(format!(
"Missing {field} for {op}. Example request: {example}"
))
};
match 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"
));
}
for (index, step) in steps.iter().enumerate() {
let step = step
.as_object()
.ok_or_else(|| format!("batch step {index} must be an object"))?;
if step.contains_key("request_id") {
return Err(format!(
"batch step {index} must omit request_id; the batch assigns idempotency keys"
));
}
let step = Value::Object(step.clone());
let step_op = step["op"]
.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}"));
}
validate_execute_request(&step, step_op)
.map_err(|error| format!("batch step {index}: {error}"))?;
}
Ok(())
}
"open" if request["path"].as_str().is_none_or(str::is_empty) => {
missing("path", r#"{"op":"open","path":"/path/drawing.dxf"}"#)
}
"activate" if request["document_id"].as_u64().is_none() => {
missing("document_id", r#"{"op":"activate","document_id":2}"#)
}
"run" | "start" if request["cmd"].as_str().is_none_or(str::is_empty) => {
missing("cmd", r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#)
}
"input" => match request["kind"].as_str() {
Some("text") => Ok(()),
Some("token")
if request["text"]
.as_str()
.is_some_and(|value| !value.is_empty()) =>
{
Ok(())
}
Some("token") => missing("text", r#"{"op":"input","kind":"token","text":"C"}"#),
Some("point")
if request["point"].as_array().is_some_and(|values| {
values.len() == 3 && values.iter().all(Value::is_number)
}) =>
{
Ok(())
}
Some("point") => missing(
"point",
r#"{"op":"input","kind":"point","point":[0,0,0],"space":"wcs"}"#,
),
Some("entity" | "structure")
if request["handle"]
.as_str()
.is_some_and(|value| !value.is_empty())
&& request["point"].as_array().is_some_and(|values| {
values.len() == 3 && values.iter().all(Value::is_number)
}) =>
{
Ok(())
}
Some("entity" | "structure") => missing(
"handle and point",
r#"{"op":"input","kind":"entity","handle":"2A","point":[0,0,0]}"#,
),
Some("selection" | "enter") => Ok(()),
Some(kind) => Err(format!(
"Unknown input kind {kind}. Use text, token, point, entity, structure, selection or enter"
)),
None => missing("kind", r#"{"op":"input","kind":"point","point":[0,0,0]}"#),
},
"property"
if request["field"].as_str().is_none_or(str::is_empty)
|| request.get("value").is_none() =>
{
missing(
"field or value",
r#"{"op":"property","field":"color","value":1}"#,
)
}
"set_properties"
if request["collection"].as_str().is_none_or(str::is_empty)
|| request["updates"].as_array().is_none_or(Vec::is_empty) =>
{
missing(
"collection or updates",
r#"{"op":"set_properties","collection":"entities","handle":"2A","updates":[{"path":"/common/layer","value":"Walls"}]}"#,
)
}
"action" => match request["name"].as_str() {
Some(name) if crate::app::automation_action_names().contains(&name) => Ok(()),
Some(name) => Err(format!(
"Unknown action {name}. Call ocs_read commands to list actions"
)),
None => missing("name", r#"{"op":"action","name":"zoom_extents"}"#),
},
"embed_image" if request["path"].as_str().is_none_or(str::is_empty) => {
missing(
"path",
r#"{"op":"embed_image","path":"/path/logo.png","at":[0,0,0],"width":100}"#,
)
}
"wblock" if request["path"].as_str().is_none_or(str::is_empty) => {
missing(
"path",
r#"{"op":"wblock","path":"/path/part.dwg","handles":["2A","31"]}"#,
)
}
"plot" if request["path"].as_str().is_none_or(str::is_empty) => {
missing(
"path",
r#"{"op":"plot","path":"/path/pages.pdf","layout":"Model"}"#,
)
}
"entities_create"
if request["entities"].as_array().is_none_or(Vec::is_empty) =>
{
missing(
"entities",
r#"{"op":"entities_create","entities":[{"type":"Circle","center":[0,0,0],"radius":5}]}"#,
)
}
"entities_delete" | "entities_transform" | "view_focus"
if request["handles"].as_array().is_none_or(Vec::is_empty) =>
{
missing(
"handles",
r#"{"op":"entities_delete","handles":["2A","31"]}"#,
)
}
"block_define"
if request["name"].as_str().is_none_or(str::is_empty)
|| request["handles"].as_array().is_none_or(Vec::is_empty)
|| request["base"].as_array().is_none() =>
{
missing(
"name, handles and base",
r#"{"op":"block_define","name":"MARK","base":[0,0,0],"handles":["2A"]}"#,
)
}
"xdata_set"
if request["app"].as_str().is_none_or(str::is_empty)
|| request["handles"].as_array().is_none_or(Vec::is_empty) =>
{
missing(
"app and handles",
r#"{"op":"xdata_set","app":"SPM","handles":["2A"],"data":[{"code":1000,"value":"tag"}]}"#,
)
}
"entities_copy_to"
if request["handles"].as_array().is_none_or(Vec::is_empty)
|| request["document_id"].as_u64().is_none() =>
{
missing(
"handles and document_id",
r#"{"op":"entities_copy_to","handles":["2A"],"document_id":2}"#,
)
}
"group_create" | "selection_set_save"
if request["name"].as_str().is_none_or(str::is_empty)
|| request["handles"].as_array().is_none_or(Vec::is_empty) =>
{
missing(
"name and handles",
r#"{"op":"group_create","name":"Frame","handles":["2A"]}"#,
)
}
"selection_set_load" if request["name"].as_str().is_none_or(str::is_empty) => {
missing("name", r#"{"op":"selection_set_load","name":"Frame"}"#)
}
"block_delete" if request["name"].as_str().is_none_or(str::is_empty) => {
missing("name", r#"{"op":"block_delete","name":"Frame"}"#)
}
"layout_create" if request["name"].as_str().is_none_or(str::is_empty) => {
missing("name", r#"{"op":"layout_create","name":"Plan"}"#)
}
"page_setup_set" if request["layout"].as_str().is_none_or(str::is_empty) => {
missing(
"layout",
r#"{"op":"page_setup_set","layout":"Plan","paper":"ISO_A4_(210.00_x_297.00_MM)"}"#,
)
}
"save_verified" if request["path"].as_str().is_none_or(str::is_empty) => {
missing(
"path",
r#"{"op":"save_verified","request_id":"deliver-1","path":"/absolute/output.dwg","target_version":"2018"}"#,
)
}
_ => Ok(()),
}
}
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());
}
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 response = if op == "batch" {
gui.execute_batch(request, wait)?
} else {
gui.request(request, wait)?
};
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}")),
}
}
fn batch_step_schema() -> Value {
json!({
"type":"object",
"properties":{
"op":{"type":"string","enum":BATCH_STEP_OPS},
"document_id":{"type":"integer","minimum":0},
"cmd":{"type":"string","minLength":1},
"path":{"type":"string","minLength":1},
"target_format":{"type":"string","enum":["dwg","dxf"]},
"target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"]},
"allow_lossy":{"type":"boolean","default":false},
"kind":{"type":"string","enum":["text","token","point","entity","structure","selection","enter"]},
"text":{"type":"string"},
"point":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},
"space":{"type":"string","enum":["wcs","ucs","relative"]},
"handle":{"type":"string"},
"handles":{"type":"array","items":{"type":"string"}},
"type":{"type":"string"},
"layer":{"type":"string"},
"clear":{"type":"boolean"},
"field":{"type":"string"},
"value":{"description":"New property value; its JSON type must match the property kind.","anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"object"},{"type":"array"},{"type":"null"}]},
"collection":{"type":"string"},
"updates":{"type":"array","minItems":1,"items":{"type":"object","properties":{"path":{"type":"string","pattern":"^/"},"value":{},"expected":{}},"required":["path","value"],"additionalProperties":false}},
"name":{"type":"string","enum":crate::app::automation_action_names()}
},
"required":["op"],
"additionalProperties":false
})
}
fn execute_request_schema() -> Value {
let handle = json!({
"type":"string",
"pattern":"^(0[xX])?[0-9A-Fa-f]+$",
"description":"Entity handle returned by ocs_read entities or state selection."
});
let point = json!({
"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3,
"description":"Finite [x,y,z] coordinates."
});
json!({
"type":"object",
"properties":{
"op":{"type":"string","enum":EXECUTE_OPS,"description":"Semantic editor operation."},
"request_id":{"type":"string","minLength":1,"maxLength":128,"description":"Caller-generated idempotency key. Reuse it only when retrying the identical request."},
"document_id":{"type":"integer","minimum":0,"description":"Target document from current state."},
"revision":{"type":"integer","minimum":0,"description":"Expected edit revision from current state."},
"geometry_revision":{"type":"integer","minimum":0,"description":"Expected geometry revision when geometry state matters."},
"camera_revision":{"type":"integer","minimum":0,"description":"Expected camera revision when view state matters."},
"selection":{"type":"array","items":handle.clone(),"description":"Expected selected handles from current state."},
"cmd":{"type":"string","minLength":1,"description":"Command name followed by its prompt answers separated by spaces. Points use x,y or x,y,z; option answers use their token. Read command details first when unsure.","examples":["LINE 0,0 10,10","CIRCLE 5,5 3","PLINE 0,0 10,0 10,10 C"]},
"path":{"type":"string","minLength":1,"description":"Absolute path: drawing for open or save, image file for embed_image, target DWG/DXF for wblock, target PDF for plot; save_verified requires .dwg or .dxf."},
"block":{"type":"string","description":"Block definition name for wblock (exports its entities flattened into model space)."},
"linked":{"type":"boolean","description":"embed_image: true stores a path-linked RasterImage instead of an embedded OLE2FRAME."},
"layout":{"type":"string","description":"plot: Model (default), a layout name, or all."},
"area":{"type":"string","enum":["extents","display","limits","window","layout"],"description":"plot area; layout applies to paper-space layouts."},
"window":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"World [x0,y0,x1,y1] plot rectangle for area window."},
"paper":{"type":"string","description":"plot: canonical paper-catalog sheet name."},
"orientation":{"type":"string","enum":["Portrait","Landscape"],"description":"plot sheet orientation."},
"fit":{"type":"boolean","description":"plot: scale content to fit the sheet (default true)."},
"scale":{"type":"string","description":"plot scale as paper:drawing, e.g. 1:100; disables fit."},
"center":{"type":"boolean","description":"plot: center the content on the sheet (default true)."},
"offset_x":{"type":"number","description":"plot offset when not centered."},
"offset_y":{"type":"number","description":"plot offset when not centered."},
"upside_down":{"type":"boolean","description":"plot: rotate content 180 degrees."},
"plot_style":{"type":"string","description":"plot: CTB file path or a discovered plot style name."},
"transparency":{"type":"boolean","description":"plot: keep transparency."},
"lineweights":{"type":"boolean","description":"plot: honor object lineweights."},
"merge_lines":{"type":"boolean","description":"plot: merge overlapping lines."},
"stamp":{"type":"boolean","description":"plot: draw the plot stamp."},
"entities":{"type":"array","minItems":1,"description":"entities_create: typed definitions, one object per entity. type is one of Line, Circle, Arc, LwPolyline, Point, Text, MText, Insert, Solid, Hatch; geometry fields depend on the type; layer and color (ACI) are optional and a missing layer is created.","items":{"type":"object"}},
"action":{"type":"string","enum":["move","copy","rotate","scale","mirror","array"],"description":"entities_transform action (default move)."},
"vector":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"move/copy displacement [dx,dy(,dz)]."},
"center":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"rotate/scale center point."},
"angle_deg":{"type":"number","description":"rotate angle, degrees CCW."},
"factor":{"type":"number","exclusiveMinimum":0,"description":"scale factor."},
"axis":{"type":"array","minItems":2,"maxItems":2,"description":"mirror line [[x1,y1],[x2,y2]].","items":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":2}},
"copy":{"type":"boolean","description":"mirror: keep the originals and return mirrored copies."},
"rows":{"type":"integer","minimum":1,"description":"array row count (default 1)."},
"columns":{"type":"integer","minimum":1,"description":"array column count (default 1)."},
"row_spacing":{"type":"number","description":"array row spacing."},
"column_spacing":{"type":"number","description":"array column spacing."},
"base":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"block_define base point (becomes the block origin)."},
"insert_at":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"block_define Insert placement (default = base)."},
"app":{"type":"string","minLength":1,"description":"xdata_set: application name; its RegApp table entry is registered automatically."},
"data":{"type":"array","description":"xdata_set typed values [{code,value}]: 1000 string, 1003 layer, 1004 hex bytes, 1005 hex handle, 1010-1013 [x,y,z], 1040/1041/1042 real, 1070 int16, 1071 int32. An empty or absent list removes the application record.","items":{"type":"object","properties":{"code":{"type":"integer"},"value":{}},"required":["code","value"]}},
"highlight":{"type":"boolean","description":"view_focus: also select the entities (default true)."},
"template":{"type":"string","description":"new/wblock: load this DWG/DXF/DWT file as the base document so its tables and styles survive."},
"discard":{"type":"boolean","description":"close: true erases unsaved changes instead of refusing a dirty document."},
"per_page":{"type":"boolean","description":"plot: write one PDF per layout as <stem>-<Layout>.pdf."},
"get":{"type":"array","items":{"type":"string"},"description":"sysvar: variable names to read (ltscale, pdmode, pdsize, celtscale, textsize, filletrad, mirrtext, insunits, osmode, clayer, ctextstyle, extmin, extmax)."},
"set":{"type":"object","description":"sysvar: name=value pairs to write in one undo step.","additionalProperties":true},
"select":{"type":"boolean","description":"selection_set_load: also select the recalled entities (default true)."},
"where":{"type":"array","description":"query: cross-property filters over entity properties with RFC 6901 paths and the records operator set.","items":{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string"},"value":{}},"required":["path"]}},
"target_format":{"type":"string","enum":["dwg","dxf"],"description":"Explicit output format; it must match the path extension."},
"target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"],"description":"Explicit CAD output version. Omit only to preserve the document version."},
"allow_lossy":{"type":"boolean","default":false,"description":"Acknowledge dropping unsupported passthrough records reported by audit."},
"overwrite":{"type":"boolean","default":false,"description":"For save_verified only: replace an existing destination."},
"at":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"World [x,y] or [x,y,z] placement corner for embed_image (picture grows up-right)."},
"width":{"type":"number","exclusiveMinimum":0,"description":"World width for embed_image; height follows the image aspect ratio. Defaults to pixel_width/100."},
"kind":{"type":"string","enum":["text","token","point","entity","structure","selection","enter"],"description":"Input kind listed in state.command.accepts."},
"text":{"type":"string","description":"Free text or one option/value token."},
"point":point,
"space":{"type":"string","enum":["wcs","ucs","relative"],"default":"wcs","description":"Coordinate space for point input."},
"handle":handle.clone(),
"handles":{"type":"array","items":handle,"description":"Entity handles: selection filter for select, export set for wblock."},
"type":{"type":"string","description":"Entity type filter for select."},
"layer":{"type":"string","description":"Layer filter for select."},
"clear":{"type":"boolean","description":"Clear the current selection before applying select filters."},
"field":{"type":"string","minLength":1,"description":"Property id returned by ocs_read properties."},
"value":{"description":"New property value; its JSON type must match the property kind.","anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"object"},{"type":"array"},{"type":"null"}]},
"collection":{"type":"string","description":"Record collection returned by ocs_read records."},
"updates":{"type":"array","minItems":1,"description":"Atomic, type-checked property replacements. Paths are RFC 6901 JSON Pointers relative to record.properties.","items":{"type":"object","properties":{"path":{"type":"string","pattern":"^/"},"value":{},"expected":{"description":"Optional compare-and-set value."}},"required":["path","value"],"additionalProperties":false}},
"name":{"type":"string","enum":crate::app::automation_action_names(),"description":"UI action returned by ocs_read commands."},
"steps":{"type":"array","minItems":1,"maxItems":MAX_BATCH_STEPS,"description":"Sequential editor operations executed with fresh state and idempotency keys. Execution stops at the first failure; completed_steps says what committed.","items":batch_step_schema()}
},
"required":["op","request_id"],
"additionalProperties":false,
"oneOf":[
{"properties":{"op":{"const":"new"}}},
{"properties":{"op":{"const":"open"}},"required":["path"]},
{"properties":{"op":{"const":"activate"}},"required":["document_id"]},
{"properties":{"op":{"const":"run"}},"required":["cmd"]},
{"properties":{"op":{"const":"start"}},"required":["cmd"]},
{"properties":{"op":{"const":"input"}},"required":["kind"],"oneOf":[
{"properties":{"kind":{"const":"text"}}},
{"properties":{"kind":{"const":"token"}},"required":["text"]},
{"properties":{"kind":{"const":"point"}},"required":["point"]},
{"properties":{"kind":{"const":"entity"}},"required":["handle","point"]},
{"properties":{"kind":{"const":"structure"}},"required":["handle","point"]},
{"properties":{"kind":{"const":"selection"}}},
{"properties":{"kind":{"const":"enter"}}}
]},
{"properties":{"op":{"const":"cancel"}}},
{"properties":{"op":{"const":"undo"}}},
{"properties":{"op":{"const":"redo"}}},
{"properties":{"op":{"const":"select"}}},
{"properties":{"op":{"const":"property"}},"required":["field","value"]},
{"properties":{"op":{"const":"set_properties"}},"required":["collection","updates"]},
{"properties":{"op":{"const":"action"}},"required":["name"]},
{"properties":{"op":{"const":"embed_image"}},"required":["path"]},
{"properties":{"op":{"const":"wblock"}},"required":["path"]},
{"properties":{"op":{"const":"plot"}},"required":["path"]},
{"properties":{"op":{"const":"entities_create"}},"required":["entities"]},
{"properties":{"op":{"const":"entities_delete"}},"required":["handles"]},
{"properties":{"op":{"const":"entities_transform"}},"required":["handles"]},
{"properties":{"op":{"const":"block_define"}},"required":["name","base","handles"]},
{"properties":{"op":{"const":"block_delete"}},"required":["name"]},
{"properties":{"op":{"const":"xdata_set"}},"required":["app","handles"]},
{"properties":{"op":{"const":"view_focus"}},"required":["handles"]},
{"properties":{"op":{"const":"entities_copy_to"}},"required":["handles","document_id"]},
{"properties":{"op":{"const":"group_create"}},"required":["name","handles"]},
{"properties":{"op":{"const":"selection_set_save"}},"required":["name","handles"]},
{"properties":{"op":{"const":"selection_set_load"}},"required":["name"]},
{"properties":{"op":{"const":"user_select"},"description":"Ask the person at the screen to pick entities; resolves when they press Enter (or cancel on Escape). Optional type/layer/prompt/detail/clear."}},
{"properties":{"op":{"const":"getpoint"},"description":"Ask the person at the screen to pick one point; resolves with the picked point when they click, or cancels on Escape. Optional prompt."}},
{"properties":{"op":{"const":"close"}}},
{"properties":{"op":{"const":"sysvar"}}},
{"properties":{"op":{"const":"layout_create"}},"required":["name"]},
{"properties":{"op":{"const":"page_setup_set"}},"required":["layout"]},
{"properties":{"op":{"const":"file_identity"}}},
{"properties":{"op":{"const":"save"}}},
{"properties":{"op":{"const":"save_verified"}},"required":["path"]},
{"properties":{"op":{"const":"stop"}}},
{"properties":{"op":{"const":"batch"}},"required":["steps"]}
]
})
}
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."},"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 stdin.lock().lines() {
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"])
);
assert_eq!(
tools[2]["inputSchema"]["properties"]["request"]["oneOf"]
.as_array()
.unwrap()
.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"
);
assert_eq!(
tools[2]["inputSchema"]["properties"]["request"]["properties"]["cmd"]["examples"][0],
"LINE 0,0 10,10"
);
assert!(READ_OPS.contains(&"capabilities"));
assert!(READ_OPS.contains(&"records"));
assert!(READ_OPS.contains(&"record_schema"));
assert!(READ_OPS.contains(&"audit"));
assert!(EXECUTE_OPS.contains(&"set_properties"));
assert!(EXECUTE_OPS.contains(&"save_verified"));
assert_eq!(
tools[2]["inputSchema"]["properties"]["request"]["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"
])
);
assert_eq!(
tools[2]["inputSchema"]["properties"]["request"]["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");
}
}