mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-29 13:26:49 -06:00
Workspace (geom, dxf, color, doc, fonts, render, io, engine, ui-egui, mcp; apps cadcraft, cadcraft-cli, cadcraft-web; xtask), AutoCAD-for-Mac style UI with code-drawn icons, command line with prompts/keywords/AutoComplete, draw and modify commands, object snaps, polar/ortho, selection, undo, layers and properties palettes, DXF read/write, SVG/PNG export, MCP server, JSON control channel, CLI, wasm build, README, ROADMAP, AGENTS.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
134 lines
5.6 KiB
Rust
134 lines
5.6 KiB
Rust
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources methods.
|
|
|
|
use std::io::{BufRead, Write};
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::backend::Backend;
|
|
use crate::tools::{call_tool, tool_definitions};
|
|
|
|
pub const PROTOCOL_VERSION: &str = "2025-06-18";
|
|
const SUPPORTED: &[&str] = &[PROTOCOL_VERSION, "2025-03-26", "2024-11-05"];
|
|
|
|
const PARSE_ERROR: i64 = -32700;
|
|
const INVALID_REQUEST: i64 = -32600;
|
|
const METHOD_NOT_FOUND: i64 = -32601;
|
|
const INVALID_PARAMS: i64 = -32602;
|
|
|
|
const INSTRUCTIONS: &str = "CADCraft is a 2D/3D CAD drafting app (an AutoCAD clone). Coordinates are drawing units, \
|
|
y up. Fastest path: command_line with AutoCAD syntax, e.g. `line 0,0 10,0 10,5 c`, `circle 5,2.5 1`, \
|
|
`rectang 0,0 4,3`, `offset 1` then pick, `zoom e`. Or execute with JSON (list_commands shows ids and params). \
|
|
Verify with inspect_drawing / query_entities and look with render.";
|
|
|
|
pub struct Server {
|
|
backend: Box<dyn Backend>,
|
|
initialized: bool,
|
|
}
|
|
|
|
fn response(id: Value, result: Value) -> Value {
|
|
json!({"jsonrpc": "2.0", "id": id, "result": result})
|
|
}
|
|
fn error(id: Value, code: i64, message: impl Into<String>) -> Value {
|
|
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}})
|
|
}
|
|
|
|
impl Server {
|
|
pub fn new(backend: Box<dyn Backend>) -> Self {
|
|
Self { backend, initialized: false }
|
|
}
|
|
pub fn backend(&mut self) -> &mut dyn Backend {
|
|
self.backend.as_mut()
|
|
}
|
|
pub fn is_initialized(&self) -> bool {
|
|
self.initialized
|
|
}
|
|
|
|
/// Serve newline-delimited JSON-RPC until `input` closes (logs to stderr only).
|
|
pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
|
|
for line in input.lines() {
|
|
let line = line?;
|
|
if let Some(reply) = self.handle_line(&line) {
|
|
output.write_all(reply.as_bytes())?;
|
|
output.write_all(b"\n")?;
|
|
output.flush()?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn handle_line(&mut self, line: &str) -> Option<String> {
|
|
let line = line.trim();
|
|
if line.is_empty() {
|
|
return None;
|
|
}
|
|
let reply = match serde_json::from_str::<Value>(line) {
|
|
Ok(Value::Array(batch)) => {
|
|
let replies: Vec<Value> = batch.into_iter().filter_map(|m| self.handle(m)).collect();
|
|
(!replies.is_empty()).then_some(Value::Array(replies))
|
|
}
|
|
Ok(msg) => self.handle(msg),
|
|
Err(e) => Some(error(Value::Null, PARSE_ERROR, format!("parse error: {e}"))),
|
|
};
|
|
reply.map(|r| r.to_string())
|
|
}
|
|
|
|
pub fn handle(&mut self, msg: Value) -> Option<Value> {
|
|
let Value::Object(o) = &msg else { return Some(error(Value::Null, INVALID_REQUEST, "message must be an object")) };
|
|
let id = o.get("id").cloned();
|
|
let Some(method) = o.get("method").and_then(Value::as_str) else {
|
|
if o.contains_key("result") || o.contains_key("error") {
|
|
return None;
|
|
}
|
|
return Some(error(id.unwrap_or(Value::Null), INVALID_REQUEST, "missing `method`"));
|
|
};
|
|
let params = o.get("params").cloned().unwrap_or(Value::Null);
|
|
let Some(id) = id else {
|
|
if method == "notifications/initialized" {
|
|
self.initialized = true;
|
|
}
|
|
return None;
|
|
};
|
|
Some(match self.request(method, ¶ms) {
|
|
Ok(r) => response(id, r),
|
|
Err((code, m)) => error(id, code, m),
|
|
})
|
|
}
|
|
|
|
fn request(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
|
|
match method {
|
|
"initialize" => {
|
|
let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION);
|
|
let version = if SUPPORTED.contains(&asked) { asked } else { PROTOCOL_VERSION };
|
|
Ok(json!({
|
|
"protocolVersion": version,
|
|
"capabilities": {"tools": {"listChanged": false}, "resources": {"listChanged": false}},
|
|
"serverInfo": {"name": "cadcraft", "title": "CADCraft", "version": env!("CARGO_PKG_VERSION")},
|
|
"instructions": format!("{INSTRUCTIONS} Backend: {}.", self.backend.describe()),
|
|
}))
|
|
}
|
|
"ping" => Ok(json!({})),
|
|
"tools/list" => Ok(tool_definitions(self.backend.has_ui())),
|
|
"tools/call" => {
|
|
let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?;
|
|
let args = params.get("arguments").cloned().unwrap_or(json!({}));
|
|
Ok(call_tool(self.backend.as_mut(), name, &args))
|
|
}
|
|
"resources/list" => Ok(json!({"resources": [
|
|
{"uri": "cadcraft://drawing", "name": "Active drawing", "mimeType": "application/json"},
|
|
{"uri": "cadcraft://commands", "name": "Command catalog", "mimeType": "application/json"},
|
|
]})),
|
|
"resources/read" => {
|
|
let uri = params.get("uri").and_then(Value::as_str).unwrap_or("");
|
|
let v = match uri {
|
|
"cadcraft://drawing" => self.backend.call("drawing.inspect", json!({})),
|
|
"cadcraft://commands" => self.backend.call("engine.commands", json!({})),
|
|
_ => return Err((-32002, format!("unknown resource `{uri}`"))),
|
|
}
|
|
.map_err(|e| (-32603, e))?;
|
|
Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": v.to_string()}]}))
|
|
}
|
|
_ => Err((METHOD_NOT_FOUND, format!("method `{method}` not found"))),
|
|
}
|
|
}
|
|
}
|