1use 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
38const PROTOCOL_VERSION: &str = "2025-06-18";
40
41const WORTH_SAYING: Duration = Duration::from_secs(1);
44
45const 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
62fn 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
67struct 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
104pub struct McpServer {
106 session: Session,
107 heard: Option<(Cancelled, Out)>,
110 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 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 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 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 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
321fn 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
331type Cancelled = Arc<Mutex<HashSet<String>>>;
333
334#[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 let _ = writeln!(stdout, "{message}").and_then(|()| stdout.flush());
344 }
345}
346
347fn 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
373pub fn serve() -> GeopResult<()> {
376 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 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}