Skip to main content

geop/
mcp.rs

1//! `geop mcp`: the editor as a Model Context Protocol server.
2//!
3//! An agent (Claude Code, `claude mcp add geop -- geop mcp`) speaks JSON-RPC
4//! 2.0 to it over stdin and stdout, one message per line, as MCP's stdio
5//! transport does. The server offers the editor's agent tools
6//! ([`geop_core_editor::agent`], which the web app's assistant is offered
7//! too), every one of them: here, those that take a path read and write
8//! files on disk.
9//!
10//! The handful of methods MCP needs are written out here rather than
11//! taken from a crate: `initialize`, `ping`, `tools/list` and `tools/call`,
12//! `resources/list`, `resources/templates/list` and `resources/read`,
13//! `prompts/list` and `prompts/get`, and notifications, which are not
14//! answered. See `docs/mcp.md`.
15//!
16//! The kernel asks one thing at a time. Calls wait their turn on a queue,
17//! and a thread reads the input meanwhile, so that `ping` is answered and a
18//! cancellation heard while a long call runs: it tells the call to stop at
19//! its next turn (see [`geop_core_program::watch`]), or a call waiting not
20//! to start. A long call's answer says how long it took and waited.
21
22use std::{
23    collections::{BTreeMap, HashSet},
24    io::{BufRead, Write},
25    path::Path,
26    sync::{Arc, Mutex, mpsc},
27    time::{Duration, Instant},
28};
29
30use geop_cad_base::editor;
31use geop_core_editor::agent::{
32    AgentResource, Contents, Files, Session, prompts, resource_templates, resources, tools,
33};
34use geop_core_math::geop_error::{GeopError, GeopResult};
35use geop_core_program::watch::{Progress, watching};
36use serde_json::{Value, json};
37
38/// The MCP revision answered to `initialize`.
39const PROTOCOL_VERSION: &str = "2025-06-18";
40
41/// How long a call has to have taken, waiting included, for its answer to
42/// say so.
43const WORTH_SAYING: Duration = Duration::from_secs(1);
44
45/// What the server tells a client about itself at `initialize`.
46const INSTRUCTIONS: &str = "geop is a parametric CAD kernel. A part is a program of steps, each an \
47operation with arguments; steps refer to what earlier steps built by stable names such as \
48`extrude(box)` (the solid the step `box` extruded) or `extrude(box,end)` (its end cap). Start \
49with `new_part` or `load_program`, see what operations there are with `list_operations` and how \
50one's arguments look with `list_operations` given its `kind`, add steps with `add_step`, check \
51`run` for errors, find names for references with `list_elements`, `look` at the part to see it, \
52and finish with `export` or `save`. A sketch is drawn on a plane such as \
53`{\"datum\": \"origin:xy\"}` (or `origin:yz`, `origin:zx`). A step names what another step \
54built by its target, `/:<target>:<step id>`: the sketch the step `base` drew is \
55`{\"target\": \"/:sketch:base\"}`, and a reference left blank in `default_args` (`/:sketch:`) \
56has to be filled in. A step that names a target no step before it produces says which targets \
57there are. What is easier done than written — drawing a sketch, dragging a placed part — is done \
58as a user does it: `open_step`, then `step_event` (clicks at pixels of the last `look`, or at \
59points of the world, keys), then `commit_step`; `undo` takes an edit back. The resources \
60`geop://examples/{name}` are whole example programs.";
61
62/// The JSON-RPC error `code` with `message`.
63fn rpc_error(id: Value, code: i64, message: impl Into<String>) -> Value {
64    json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message.into() } })
65}
66
67/// The files on disk, as the tools that take a path read and write them.
68struct Disk;
69
70impl Files for Disk {
71    fn read(&mut self, path: &str) -> GeopResult<String> {
72        std::fs::read_to_string(path).map_err(|e| GeopError::new(format!("reading {path}: {e}")))
73    }
74
75    fn programs_beside(&mut self, path: &str) -> BTreeMap<String, Option<String>> {
76        let mut files = BTreeMap::new();
77        let dir = Path::new(path)
78            .parent()
79            .filter(|d| !d.as_os_str().is_empty());
80        if let Some(dir) = dir
81            && let Ok(entries) = std::fs::read_dir(dir)
82        {
83            for entry in entries.flatten() {
84                let file = entry.path();
85                if file.extension().is_some_and(|e| e == "geop")
86                    && let Ok(text) = std::fs::read_to_string(&file)
87                {
88                    files.insert(file.to_string_lossy().into_owned(), Some(text));
89                }
90            }
91        }
92        files
93    }
94
95    fn write(&mut self, path: &str, bytes: &[u8]) -> GeopResult<()> {
96        std::fs::write(path, bytes).map_err(|e| GeopError::new(format!("writing {path}: {e}")))
97    }
98
99    fn today(&self) -> String {
100        super::today()
101    }
102}
103
104/// One editor, driven by tools.
105pub struct McpServer {
106    session: Session,
107    /// What is cancelled, and where to say how far a call is, as `serve`
108    /// has them.
109    heard: Option<(Cancelled, Out)>,
110    /// When the message being answered came: how long it waited.
111    came: Instant,
112}
113
114impl Default for McpServer {
115    fn default() -> Self {
116        Self::new()
117    }
118}
119
120impl McpServer {
121    pub fn new() -> Self {
122        Self {
123            session: Session::new(editor()),
124            heard: None,
125            came: Instant::now(),
126        }
127    }
128
129    /// Answers one JSON-RPC message, or nothing for a notification.
130    pub fn handle(&mut self, line: &str) -> Option<Value> {
131        let message: Value = match serde_json::from_str(line) {
132            Ok(message) => message,
133            Err(e) => return Some(rpc_error(Value::Null, -32700, format!("parse error: {e}"))),
134        };
135        let id = message.get("id").cloned().unwrap_or(Value::Null);
136        let method = message.get("method").and_then(Value::as_str).unwrap_or("");
137        let params = message.get("params").cloned().unwrap_or(Value::Null);
138        if method.starts_with("notifications/") || (id.is_null() && !method.is_empty()) {
139            return None;
140        }
141        let result = match method {
142            "initialize" => Ok(json!({
143                "protocolVersion": params.get("protocolVersion").cloned().unwrap_or_else(|| json!(PROTOCOL_VERSION)),
144                "capabilities": { "tools": {}, "resources": {}, "prompts": {} },
145                "serverInfo": { "name": "geop", "version": env!("CARGO_PKG_VERSION") },
146                "instructions": INSTRUCTIONS,
147            })),
148            "ping" => Ok(json!({})),
149            "tools/list" => {
150                Ok(json!({ "tools": tools().iter().map(|t| t.mcp()).collect::<Vec<_>>() }))
151            }
152            "tools/call" => {
153                let name = params.get("name").and_then(Value::as_str).unwrap_or("");
154                let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
155                let token = params.pointer("/_meta/progressToken").cloned();
156                Ok(self.call(name, &arguments, &id, token))
157            }
158            "resources/list" => Ok(
159                json!({ "resources": resources().iter().map(|r| described(r, "uri")).collect::<Vec<_>>() }),
160            ),
161            "resources/templates/list" => Ok(json!({
162                "resourceTemplates": resource_templates().iter().map(|r| described(r, "uriTemplate")).collect::<Vec<_>>(),
163            })),
164            "resources/read" => {
165                let uri = params
166                    .get("uri")
167                    .and_then(Value::as_str)
168                    .unwrap_or("")
169                    .to_string();
170                match self.guarded(&uri, |session| session.read_resource(&uri)) {
171                    Ok((Contents::Text(text), media_type)) => Ok(json!({
172                        "contents": [{ "uri": uri, "mimeType": media_type, "text": text }],
173                    })),
174                    Ok((Contents::Image(image), media_type)) => Ok(json!({
175                        "contents": [{ "uri": uri, "mimeType": media_type, "blob": image.base64() }],
176                    })),
177                    Err(message) => Err(rpc_error(id.clone(), -32002, message)),
178                }
179            }
180            "prompts/list" => Ok(json!({
181                "prompts": prompts().iter().map(|p| json!({
182                    "name": p.name,
183                    "description": p.description,
184                    "arguments": p.arguments.iter().map(|(name, description, required)| json!({
185                        "name": name, "description": description, "required": required,
186                    })).collect::<Vec<_>>(),
187                })).collect::<Vec<_>>(),
188            })),
189            "prompts/get" => {
190                let name = params
191                    .get("name")
192                    .and_then(Value::as_str)
193                    .unwrap_or("")
194                    .to_string();
195                let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
196                match self.guarded(&name, |session| session.prompt(&name, &arguments)) {
197                    Ok((description, text)) => Ok(json!({
198                        "description": description,
199                        "messages": [{ "role": "user", "content": { "type": "text", "text": text } }],
200                    })),
201                    Err(message) => Err(rpc_error(id.clone(), -32602, message)),
202                }
203            }
204            _ => Err(rpc_error(
205                id.clone(),
206                -32601,
207                format!("method not found: {method}"),
208            )),
209        };
210        Some(match result {
211            Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
212            Err(error) => error,
213        })
214    }
215
216    /// Calls the tool `name` with `arguments`, as `tools/call` answers: its
217    /// result as text, or why it failed as an error result. A kernel that
218    /// panics leaves the editor in an unknown state: a fresh one takes
219    /// over, with the program as it was before the call.
220    fn call(&mut self, name: &str, arguments: &Value, id: &Value, token: Option<Value>) -> Value {
221        let started = Instant::now();
222        let waited = started.duration_since(self.came);
223        let limit = (arguments.get("limit_seconds").and_then(Value::as_f64))
224            .map(|seconds| {
225                Duration::try_from_secs_f64(seconds)
226                    .map_err(|e| format!("`limit_seconds` is {seconds}: {e}"))
227            })
228            .transpose();
229        let answer = limit.and_then(|limit| {
230            let watcher = self.watcher(id, token, limit.map(|limit| started + limit));
231            self.guarded(name, |session| {
232                Ok(watching(watcher, || {
233                    session.call(name, arguments, Some(&mut Disk))
234                }))
235            })
236        });
237        let (text, is_error, images) = match answer {
238            Ok(answer) => (answer.text, answer.is_error, answer.images),
239            Err(crashed) => (crashed, true, Vec::new()),
240        };
241        let mut content = vec![json!({ "type": "text", "text": text })];
242        content.extend(images.iter().map(|image| {
243            json!({ "type": "image", "data": image.base64(), "mimeType": image.media_type })
244        }));
245        let took = started.elapsed();
246        if took + waited >= WORTH_SAYING {
247            let said = format!(
248                "Took {:.1} s, after waiting {:.1} s behind other calls: the kernel asks one thing at a time.",
249                took.as_secs_f64(),
250                waited.as_secs_f64()
251            );
252            content.push(json!({ "type": "text", "text": said }));
253        }
254        json!({ "content": content, "isError": is_error })
255    }
256
257    /// What watches the call `id`: it tells the client how far it is, if
258    /// the client gave a `token` to say it by, no more than once a second,
259    /// and stops it once it is cancelled, or at `deadline`.
260    fn watcher(
261        &self,
262        id: &Value,
263        token: Option<Value>,
264        deadline: Option<Instant>,
265    ) -> impl FnMut(&Progress) -> bool + 'static {
266        let heard = self.heard.clone();
267        let id = id.to_string();
268        let (mut last, mut told) = (None::<Instant>, 0_u64);
269        move |progress: &Progress| {
270            let now = Instant::now();
271            if let (Some(token), Some((_, out))) = (&token, &heard)
272                && last.is_none_or(|last| now.duration_since(last) >= Duration::from_secs(1))
273            {
274                last = Some(now);
275                told += 1;
276                out.send(&json!({
277                    "jsonrpc": "2.0",
278                    "method": "notifications/progress",
279                    "params": {
280                        "progressToken": token,
281                        "progress": told,
282                        "message": format!("{}: {} of {}", progress.what, progress.done, progress.total),
283                    },
284                }));
285            }
286            let cancelled = heard.as_ref().is_some_and(|(cancelled, _)| {
287                (cancelled.lock().unwrap_or_else(|e| e.into_inner())).contains(&id)
288            });
289            !cancelled && deadline.is_none_or(|deadline| now < deadline)
290        }
291    }
292
293    /// `f` on the session, or why it failed. A kernel that panics leaves
294    /// the editor in an unknown state: a fresh one takes over, with the
295    /// program as it was before, and the panic is told as what `what` did.
296    fn guarded<T>(
297        &mut self,
298        what: &str,
299        f: impl FnOnce(&mut Session) -> GeopResult<T>,
300    ) -> Result<T, String> {
301        let answer =
302            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&mut self.session)));
303        match answer {
304            Ok(answer) => answer.map_err(|e| e.to_string()),
305            Err(panic) => {
306                let panic = panic
307                    .downcast_ref::<String>()
308                    .cloned()
309                    .or_else(|| panic.downcast_ref::<&str>().map(|s| s.to_string()))
310                    .unwrap_or_else(|| "unknown panic".to_string());
311                self.session.restart(editor());
312                Err(format!(
313                    "{what} crashed the kernel ({panic}); the editor was restarted with the \
314                     program as it was before"
315                ))
316            }
317        }
318    }
319}
320
321/// A resource as `resources/list` describes it, its URI under `uri`.
322fn described(resource: &AgentResource, uri: &str) -> Value {
323    json!({
324        uri: resource.uri,
325        "name": resource.name,
326        "description": resource.description,
327        "mimeType": resource.media_type,
328    })
329}
330
331/// The ids of the requests a client cancelled.
332type Cancelled = Arc<Mutex<HashSet<String>>>;
333
334/// Where answers and notifications go: one message per line, whichever
335/// thread sends it.
336#[derive(Clone)]
337struct Out(Arc<Mutex<std::io::Stdout>>);
338
339impl Out {
340    fn send(&self, message: &Value) {
341        let mut stdout = self.0.lock().unwrap_or_else(|e| e.into_inner());
342        // Nobody left to tell, if the client has gone: stdin closes next.
343        let _ = writeln!(stdout, "{message}").and_then(|()| stdout.flush());
344    }
345}
346
347/// Reads the input until it closes: `ping` and cancellations at once, the
348/// rest onto the queue, each with when it came.
349fn read_input(queue: mpsc::Sender<(String, Instant)>, cancelled: Cancelled, out: Out) {
350    for line in std::io::stdin().lock().lines() {
351        let Ok(line) = line else { return };
352        if line.trim().is_empty() {
353            continue;
354        }
355        let message: Value = serde_json::from_str(&line).unwrap_or(Value::Null);
356        match message.get("method").and_then(Value::as_str) {
357            Some("notifications/cancelled") => {
358                let id = &message["params"]["requestId"];
359                (cancelled.lock().unwrap_or_else(|e| e.into_inner())).insert(id.to_string());
360            }
361            Some("ping") if message.get("id").is_some() => out.send(&json!({
362                "jsonrpc": "2.0", "id": message["id"], "result": {},
363            })),
364            _ => {
365                if queue.send((line, Instant::now())).is_err() {
366                    return;
367                }
368            }
369        }
370    }
371}
372
373/// Runs the server until stdin closes: one JSON-RPC message per line in,
374/// one answer per line out, nothing for a notification.
375pub fn serve() -> GeopResult<()> {
376    // The default hook prints every panic to stderr, which MCP clients log:
377    // what crashed is answered in the tool's result instead.
378    std::panic::set_hook(Box::new(|_| {}));
379    let out = Out(Arc::new(Mutex::new(std::io::stdout())));
380    let cancelled = Cancelled::default();
381    let (queue, queued) = mpsc::channel();
382    let reader = (cancelled.clone(), out.clone());
383    std::thread::spawn(move || read_input(queue, reader.0, reader.1));
384    let mut server = McpServer::new();
385    server.heard = Some((cancelled.clone(), out.clone()));
386    for (line, came) in queued {
387        let id = (serde_json::from_str::<Value>(&line).ok())
388            .and_then(|message| message.get("id").map(Value::to_string));
389        let is_cancelled = || {
390            id.as_ref().is_some_and(|id| {
391                (cancelled.lock().unwrap_or_else(|e| e.into_inner())).contains(id)
392            })
393        };
394        // Cancelled, it is answered with nothing, as MCP asks: waiting, it
395        // does not start, and running, what it had found goes nowhere.
396        if is_cancelled() {
397            continue;
398        }
399        server.came = came;
400        if let Some(answer) = server.handle(&line)
401            && !is_cancelled()
402        {
403            out.send(&answer);
404        }
405    }
406    Ok(())
407}