Compare commits

...
1 Commits
Author SHA1 Message Date
Dylan M. Taylor 8a4e275466 Route MCP protocol through shared libraries 2026-11-15 15:30:38 +00:00
5 changed files with 81 additions and 48 deletions
Generated
+19
View File
@@ -758,6 +758,8 @@ dependencies = [
"cadcraft-io",
"cadcraft-render",
"clap",
"craft-jsonrpc",
"craft-mcp",
"getrandom 0.4.3",
"libc",
"log",
@@ -1183,6 +1185,23 @@ dependencies = [
"writeable",
]
[[package]]
name = "craft-jsonrpc"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=2c9903ca1d892b5446acf3598d8b142f3e64842f#2c9903ca1d892b5446acf3598d8b142f3e64842f"
dependencies = [
"serde_json",
]
[[package]]
name = "craft-mcp"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=2c9903ca1d892b5446acf3598d8b142f3e64842f#2c9903ca1d892b5446acf3598d8b142f3e64842f"
dependencies = [
"craft-jsonrpc",
"serde_json",
]
[[package]]
name = "craft-pdf"
version = "0.1.0"
+2
View File
@@ -25,6 +25,8 @@ cadcraft-io = { path = "crates/io" }
cadcraft-engine = { path = "crates/engine" }
cadcraft-ui-egui = { path = "crates/ui-egui" }
cadcraft-mcp = { path = "crates/mcp" }
craft-mcp = { git = "https://github.com/storytold/craft-libs", rev = "2c9903ca1d892b5446acf3598d8b142f3e64842f" }
craft-jsonrpc = { git = "https://github.com/storytold/craft-libs", rev = "2c9903ca1d892b5446acf3598d8b142f3e64842f" }
# Which paths stored in a drawing (xrefs, data links) may be opened.
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# PDF writing (krilla) for plots, and reading them back in tests. The root patches below pin
+2
View File
@@ -9,6 +9,8 @@ license.workspace = true
workspace = true
[dependencies]
craft-mcp = { workspace = true }
craft-jsonrpc = { workspace = true }
cadcraft-engine = { workspace = true }
cadcraft-render = { workspace = true }
cadcraft-io = { workspace = true }
+37 -48
View File
@@ -22,25 +22,24 @@ 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()}})
session: craft_mcp::Session,
}
impl Server {
pub fn new(backend: Box<dyn Backend>) -> Self {
Self { backend, initialized: false }
Self {
backend,
session: craft_mcp::Session::new("cadcraft", env!("CARGO_PKG_VERSION"), craft_mcp::Policy::Compatible)
.with_capabilities(json!({"tools": {"listChanged": false}, "resources": {"listChanged": false}}))
.unwrap_or_else(|_| craft_mcp::Session::new("cadcraft", env!("CARGO_PKG_VERSION"), craft_mcp::Policy::Compatible))
.with_legacy_initialize(SUPPORTED, json!({"name": "cadcraft", "title": "CADCraft", "version": env!("CARGO_PKG_VERSION")})),
}
}
pub fn backend(&mut self) -> &mut dyn Backend {
self.backend.as_mut()
}
pub fn is_initialized(&self) -> bool {
self.initialized
self.session.is_initialized()
}
/// Serve newline-delimited JSON-RPC until `input` closes (logs to stderr only).
@@ -62,53 +61,43 @@ impl Server {
return None;
}
let reply = match serde_json::from_str::<Value>(line) {
// JSON-RPC 2.0: an empty batch is one Invalid Request, not silence.
Ok(Value::Array(batch)) if batch.is_empty() => Some(error(Value::Null, INVALID_REQUEST, "empty batch")),
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}"))),
Ok(msg) => self.handle_message(&msg),
Err(e) => Some(craft_jsonrpc::error_response(Value::Null, craft_jsonrpc::RpcError::new(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, &params) {
Ok(r) => response(id, r),
Err((code, m)) => error(id, code, m),
})
// `handle` historically routes only one envelope; batches belong to `handle_line`.
if msg.is_array() {
return Some(craft_jsonrpc::error_response(Value::Null, craft_jsonrpc::RpcError::new(INVALID_REQUEST, "message must be an object")));
}
self.handle_message(&msg)
}
fn handle_message(&mut self, msg: &Value) -> Option<Value> {
let server = std::cell::RefCell::new(self);
craft_jsonrpc::process_legacy(
msg,
|request| {
let mut server = server.borrow_mut();
if request.method == "initialize" {
let instructions = format!("{INSTRUCTIONS} Backend: {}.", server.backend.describe());
server.session.set_instructions(instructions);
}
if let Some(result) = server.session.request(request, msg.is_array()) {
return result;
}
server
.request(request.method, request.params.unwrap_or(&Value::Null))
.map_err(|(code, message)| craft_jsonrpc::RpcError::new(code, message))
},
|request| server.borrow_mut().session.notification(request),
)
}
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()))?;
+21
View File
@@ -391,3 +391,24 @@ fn render_refuses_unknown_backgrounds_and_plot_styles() {
assert!(crate::Backend::call(&mut h, "ui.render", json!({"background": "purple"})).is_err());
assert!(crate::Backend::call(&mut h, "ui.render", json!({"plot_style": 3})).is_err());
}
#[test]
fn legacy_protocol_routing_and_version_negotiation() {
let mut s = Server::new(Box::new(Headless::default()));
assert!(s.handle_line(r#"{"method":"notifications/initialized"}"#).is_none());
assert!(s.is_initialized());
for (params, expected) in
[(json!(null), "2025-06-18"), (json!({"protocolVersion":"2024-11-05"}), "2024-11-05"), (json!({"protocolVersion":"other"}), "2025-06-18")]
{
let response = call(&mut s, 1, "initialize", params);
assert_eq!(response["result"]["protocolVersion"], expected);
assert_eq!(response["result"]["serverInfo"]["title"], "CADCraft");
assert!(response["result"]["instructions"].as_str().unwrap().contains("Backend:"));
}
let response: Value =
serde_json::from_str(&s.handle_line(r#"[{"id":[1],"method":"ping","params":5},{"method":"ignored"},{"result":1},{}]"#).unwrap()).unwrap();
assert_eq!(
response,
json!([{"jsonrpc":"2.0","id":[1],"result":{}},{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"missing `method`"}}])
);
}