Files
vectorcraft/crates/mcp/src/server.rs
T
Fahri Meral e80b7e383b MCP: prompts, resource templates, completions and logging
The server answered tools/* and two fixed resources and nothing else, and
resources/templates/list returned an empty array. This adds the rest of the surface a
client can actually use, against 2025-06-18.

- prompts/list and prompts/get: five workflows (poster, icon-set, recolor,
  trace-and-style, export-set) with templated arguments. A missing required argument
  is -32602 and names itself.
- resources/templates/list, and resources/read for the templates: object/{id},
  command/{id}, effect/{id}, swatch/{name}. Reading one thing at a time instead of
  vectorcraft://document/json, which on a real document is thousands of lines an agent
  pays for again on every change.
- completion/complete for prompt arguments and template variables, ranked prefix
  first, capped at 100 with total and hasMore. The values are read live, so a suggested
  format, effect id, command id, trace preset, swatch or object id is one the engine
  has.
- logging/setLevel and notifications/message. Nothing installed a log::Log before, so
  the engine's log::debug! calls went nowhere; this is the sink, silent until asked.
  Installing it is vectorcraft-cli's job rather than the library's, so an embedder
  that already has a logger keeps it.

docs/mcp.md gains a Protocol section with the capability table and a Not implemented
section: subscriptions, progress, cancellation, sampling, elicitation and Streamable
HTTP all need the transport to interleave reads, which this one does not — handle_line
answers a line with a reply, so it neither reads an incoming cancellation nor can write
progress before the reply it belongs to.

62 tests (50 unit, 12 property/fuzz). protocol_props.rs gained five: prompts survive
junk arguments, completion/complete never panics and always answers at most 100
string values with a consistent hasMore, every template reads or fails cleanly, and
prompts/completions/templates are swept with junk params while asserting the document
comes back byte-identical.

ROADMAP: one bullet under Automation in Shipped so far and the changelog line. The
parity table and the gap list are untouched — MCP is agent automation, so feature
parity does not move.
2026-10-07 07:25:02 +07:00

207 lines
9.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources / prompts methods.
use std::io::{BufRead, Write};
use serde_json::{Value, json};
use crate::backend::Backend;
use crate::prompts;
use crate::resources;
use crate::tools::{call_tool, tool_definitions};
/// The MCP revision we implement.
pub const PROTOCOL_VERSION: &str = "2025-06-18";
const SUPPORTED_VERSIONS: &[&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 INTERNAL_ERROR: i64 = -32603;
const RESOURCE_NOT_FOUND: i64 = -32002;
const INSTRUCTIONS: &str = "VectorCraft is a professional vector illustration app. Coordinates are \
points in document space (y down, origin at the first artboard's top-left; a new document is 612×792). \
Draw with draw_shape / draw_path, change colours with set_paint, look with screenshot and inspect_document. \
Every menu action is a command: find it with list_commands and run it with run_command. New objects become \
the selection, and most commands act on the selection (or on explicit `ids`). prompts/list has ready-made \
workflows, completion/complete finishes a command id, a format, an effect or a swatch name, and \
vectorcraft://object/{id}, vectorcraft://command/{id}, vectorcraft://effect/{id} and \
vectorcraft://swatch/{name} read one thing at a time instead of the whole document.";
/// An MCP server bound to one backend.
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()
}
/// Whether the client has sent `notifications/initialized`.
pub fn is_initialized(&self) -> bool {
self.initialized
}
/// The `notifications/message` records queued since the last call.
///
/// Log records belong to no particular request, so these go out after the reply to whatever
/// line produced them rather than before it. [`serve`] drains this itself; a caller driving
/// [`handle_line`] by hand does the same.
pub fn take_notifications(&mut self) -> Vec<String> {
crate::logging::drain()
.into_iter()
.map(|record| json!({"jsonrpc": "2.0", "method": "notifications/message", "params": record}).to_string())
.collect()
}
/// Serve newline-delimited JSON-RPC until `input` closes. Logs go to stderr only (stdout is
/// the protocol stream).
pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
for line in input.lines() {
let line = line?;
let reply = self.handle_line(&line);
let notes = self.take_notifications();
for line in reply.iter().chain(notes.iter()) {
output.write_all(line.as_bytes())?;
output.write_all(b"\n")?;
}
output.flush()?;
}
Ok(())
}
/// Handle one line; returns the reply line (None for notifications and blank lines).
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)) => {
// Batches were removed in 2025-06-18; still answer older clients sensibly.
if batch.is_empty() {
Some(error(Value::Null, INVALID_REQUEST, "empty batch"))
} else {
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())
}
/// Handle one JSON-RPC message; `None` for notifications and responses.
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 {
// A response to a server→client request (we send none) — ignore; anything else is invalid.
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 {
self.notification(method, &params);
return None;
};
if !(id.is_string() || id.is_number()) {
return Some(error(Value::Null, INVALID_REQUEST, "`id` must be a string or number"));
}
// A bug that panics fails this request only; the server keeps serving.
let r = vectorcraft_engine::guard::catch_panic(|| self.request(method, &params))
.unwrap_or_else(|msg| Err((INTERNAL_ERROR, format!("internal error in `{method}`: {msg} (please report this bug)"))));
Some(match r {
Ok(r) => response(id, r),
Err((code, m)) => error(id, code, m),
})
}
fn notification(&mut self, method: &str, _params: &Value) {
match method {
"notifications/initialized" => self.initialized = true,
"notifications/cancelled" | "notifications/progress" | "notifications/roots/list_changed" => {}
other => log::debug!("ignoring notification {other}"),
}
}
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_VERSIONS.contains(&asked) { asked } else { PROTOCOL_VERSION };
Ok(json!({
"protocolVersion": version,
"capabilities": {
"tools": {},
"resources": {},
"prompts": {"listChanged": false},
"completions": {},
"logging": {},
},
"serverInfo": {"name": "vectorcraft", "title": "VectorCraft", "version": env!("CARGO_PKG_VERSION")},
"instructions": format!("{INSTRUCTIONS} Backend: {}.", self.backend.describe()),
}))
}
"ping" => Ok(json!({})),
"tools/list" => Ok(json!({"tools": tool_definitions()})),
"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(Value::Null);
Ok(call_tool(self.backend.as_mut(), name, &args).to_value())
}
"resources/list" => Ok(resources::list()),
"resources/templates/list" => Ok(resources::templates()),
"resources/read" => {
let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri`".to_string()))?;
let v = resources::read(self.backend.as_mut(), uri).map_err(|e| match e {
resources::ReadError::NotFound(uri) => (RESOURCE_NOT_FOUND, format!("resource not found: {uri}")),
resources::ReadError::Invalid(why) => (INVALID_PARAMS, why),
resources::ReadError::Backend(why) => (INTERNAL_ERROR, why),
})?;
Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": serde_json::to_string_pretty(&v).unwrap_or_default()}]}))
}
"prompts/list" => Ok(prompts::list()),
"prompts/get" => {
let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing prompt `name`".to_string()))?;
let args = params.get("arguments").cloned().unwrap_or(Value::Null);
prompts::get(name, &args).map_err(|e| (INVALID_PARAMS, e))
}
"completion/complete" => Ok(prompts::complete(self.backend.as_mut(), params)),
"logging/setLevel" => {
// `level` is required. A null level is our own way of turning logging back off,
// which the spec has no method for and a long session needs.
let Some(asked) = params.get("level") else {
return Err((INVALID_PARAMS, "missing `level`".into()));
};
let level = if asked.is_null() { None } else { asked.as_str() };
crate::logging::set_level(level).map_err(|_| match level {
Some(given) => (INVALID_PARAMS, format!("unknown level `{given}`")),
None => (INVALID_PARAMS, "`level` must be a severity name or null".to_string()),
})?;
log::debug!("client set logging to {}", level.unwrap_or("off"));
Ok(json!({}))
}
other => Err((METHOD_NOT_FOUND, format!("method not found: {other}"))),
}
}
}