uncad-cli 0.15.0

CLI for uncad (CAD file summary, JSON export of the parsed model, SVG/PNG rendering, read-only verbs as JSON and as MCP tools).
//! `uncad mcp`: the verb table as a Model Context Protocol server over
//! stdio.
//!
//! Every tool is a verb of [`crate::verbs`], and a tool call is
//! [`Verb::call`](crate::verbs::Verb::call) -- the same path the command
//! line takes -- so the first content block of a result is byte for byte
//! what `uncad <verb>` prints on stdout, and the blocks after it are the
//! warnings it prints on stderr. The server keeps nothing between calls:
//! each call reads its files again.
//!
//! Calls run one at a time on a single thread. Reading a DWG goes through
//! LibreDWG, which is not written for concurrent use.

use crate::verbs::VERBS;
use rmcp::model::{
    CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, Implementation,
    ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool,
    ToolAnnotations,
};
use rmcp::service::RequestContext;
use rmcp::{ErrorData, RoleServer, ServerHandler, ServiceExt};
use std::sync::Arc;

const INSTRUCTIONS: &str =
    "Questions about DWG and DXF drawings, one edit, and a picture of it. Every tool \
    reads the files it is given, by path on this machine, and answers with a JSON document; \
    the same files and arguments give the same answer. A value the drawing does not establish \
    is reported as such (absent, ambiguous with every candidate, or not searched with a \
    reason), never guessed. `set` changes one field and writes the result as a new model JSON \
    file -- never over an existing one, never into its input -- which every tool reads as a \
    drawing, so edits chain; its answer is the difference it made. `redline` draws that \
    difference on top of the original as an SVG or PNG file (a new one), for a person to see. \
    `balloon` draws labels you give, each pointing at an entity or a point, on top of a drawing \
    as an SVG file (a new one).";

struct Server;

impl ServerHandler for Server {
    fn get_info(&self) -> ServerConfig {
        ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
            .with_server_info(Implementation::new("uncad", env!("CARGO_PKG_VERSION")))
            .with_instructions(INSTRUCTIONS)
    }

    async fn list_tools(
        &self,
        _request: Option<PaginatedRequestParams>,
        _context: RequestContext<RoleServer>,
    ) -> Result<ListToolsResult, ErrorData> {
        let tools = VERBS
            .iter()
            .map(|verb| {
                Tool::new(verb.name, verb.description, Arc::new(verb.input_schema()))
                    // A verb that writes creates a new file and never
                    // overwrites one, so it is not destructive -- and not
                    // idempotent, since a second call finds its file taken.
                    .with_annotations(ToolAnnotations::from_raw(
                        None,
                        Some(!verb.writes),
                        Some(false),
                        Some(!verb.writes),
                        Some(false),
                    ))
            })
            .collect();
        // A 2026-07-28 client needs the list's cache hints; rmcp fills the
        // ones a handler leaves out with the most conservative (fresh for no
        // time, cached by this client only), which is what this server means.
        Ok(ListToolsResult::with_all_items(tools))
    }

    async fn call_tool(
        &self,
        request: CallToolRequestParams,
        _context: RequestContext<RoleServer>,
    ) -> Result<CallToolResponse, ErrorData> {
        let verb = VERBS
            .iter()
            .find(|v| v.name == request.name)
            .ok_or_else(|| {
                ErrorData::invalid_params(format!("no tool '{}'", request.name), None)
            })?;
        let args = request.arguments.unwrap_or_default();
        // A failure is the tool's answer, not a protocol error: the caller
        // reads the message and can correct the call.
        let result = match verb.call(&args) {
            Ok(answer) => {
                let mut content = vec![ContentBlock::text(answer.json)];
                content.extend(
                    answer
                        .warnings
                        .into_iter()
                        .map(|w| ContentBlock::text(format!("warning: {w}"))),
                );
                CallToolResult::success(content)
            }
            Err(message) => CallToolResult::error(vec![ContentBlock::text(message)]),
        };
        Ok(result.into())
    }
}

/// How many drawings a server keeps read: two for a comparison (`diff`,
/// `redline`) and room for the one being edited and its result.
const HELD_DRAWINGS: usize = 4;

/// Serves until the client closes stdin.
pub fn serve() -> Result<(), String> {
    // A session asks many questions about the same few drawings; reading a
    // drawing is what a call costs. Reused only for the same bytes.
    crate::verbs::hold_reads(HELD_DRAWINGS);
    let runtime = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .map_err(|e| format!("cannot start the MCP server: {e}"))?;
    runtime.block_on(async {
        let service = Server
            .serve(rmcp::transport::stdio())
            .await
            .map_err(|e| format!("MCP initialization failed: {e}"))?;
        service
            .waiting()
            .await
            .map_err(|e| format!("MCP server stopped: {e}"))?;
        Ok(())
    })
}