1pub mod tools;
13
14use eframe::egui;
15use serde_json::{json, Value};
16use std::io::{BufRead, BufReader, Read, Write};
17use std::net::{TcpListener, TcpStream};
18use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
19use std::sync::mpsc::{self, Receiver, Sender};
20use std::sync::Arc;
21use std::time::Duration;
22
23pub struct ToolCall {
25 pub name: String,
26 pub args: Value,
27 pub reply: Sender<Result<Value, String>>,
28}
29
30const MAX_BODY: usize = 8 * 1024 * 1024;
31const MAX_CONNS: usize = 16;
32const READ_TIMEOUT: Duration = Duration::from_secs(10);
33
34pub struct Server {
35 port: u16,
36 stop: Arc<AtomicBool>,
37}
38
39impl Server {
40 pub fn start(port: u16, ctx: egui::Context) -> Result<(Server, Receiver<ToolCall>), String> {
43 let listener = TcpListener::bind(("127.0.0.1", port)).map_err(|e| format!("bind 127.0.0.1:{port}: {e}"))?;
44 let port = listener.local_addr().map_err(|e| e.to_string())?.port();
45 let (tx, rx) = mpsc::channel::<ToolCall>();
46 let stop = Arc::new(AtomicBool::new(false));
47 let stop2 = stop.clone();
48 let live = Arc::new(AtomicUsize::new(0));
49 std::thread::Builder::new()
50 .name("mcp-accept".into())
51 .spawn(move || {
52 for conn in listener.incoming() {
53 if stop2.load(Ordering::Relaxed) {
54 break;
55 }
56 let Ok(mut stream) = conn else { continue };
57 if live.load(Ordering::Relaxed) >= MAX_CONNS {
58 let _ = write_response(&mut stream, 503, "application/json", b"", true);
59 continue;
60 }
61 live.fetch_add(1, Ordering::Relaxed);
62 let (tx2, ctx2, live2, stop3) = (tx.clone(), ctx.clone(), live.clone(), stop2.clone());
63 let spawned = std::thread::Builder::new()
64 .name("mcp-conn".into())
65 .spawn(move || {
66 let _ = handle_connection(stream, &tx2, &ctx2, &stop3);
67 live2.fetch_sub(1, Ordering::Relaxed);
68 })
69 .is_ok();
70 if !spawned {
71 live.fetch_sub(1, Ordering::Relaxed);
72 }
73 }
74 })
75 .map_err(|e| e.to_string())?;
76 Ok((Server { port, stop }, rx))
77 }
78
79 pub fn url(&self) -> String {
80 format!("http://127.0.0.1:{}/mcp", self.port)
81 }
82
83 #[cfg(test)]
85 pub fn port(&self) -> u16 {
86 self.port
87 }
88
89 pub fn stop(self) {
91 self.stop.store(true, Ordering::Relaxed);
92 let _ = TcpStream::connect(("127.0.0.1", self.port));
94 }
95}
96
97pub fn claude_code_command(port: u16) -> String {
99 format!("claude mcp add --transport http simple-editor http://127.0.0.1:{port}/mcp")
100}
101
102fn handle_connection(
106 mut stream: TcpStream,
107 tx: &Sender<ToolCall>,
108 ctx: &egui::Context,
109 stop: &AtomicBool,
110) -> std::io::Result<()> {
111 stream.set_read_timeout(Some(READ_TIMEOUT))?;
112 let mut reader = BufReader::new(stream.try_clone()?);
113 loop {
114 if stop.load(Ordering::Relaxed) {
115 return Ok(());
116 }
117 let mut line = String::new();
119 if reader.read_line(&mut line)? == 0 {
120 return Ok(()); }
122 if line.trim_end().is_empty() {
123 continue;
124 }
125 let mut parts = line.split_whitespace();
126 let method = parts.next().unwrap_or("").to_ascii_uppercase();
127 let path = parts.next().unwrap_or("");
128 let path = path.split('?').next().unwrap_or(path);
129 let mut content_length = 0usize;
131 let mut origin: Option<String> = None;
132 let mut accept = String::new();
133 let mut close = false;
134 loop {
135 let mut h = String::new();
136 if reader.read_line(&mut h)? == 0 {
137 return Ok(());
138 }
139 let h = h.trim_end();
140 if h.is_empty() {
141 break;
142 }
143 let Some((name, value)) = h.split_once(':') else { continue };
144 let value = value.trim();
145 match name.trim().to_ascii_lowercase().as_str() {
146 "content-length" => content_length = value.parse().unwrap_or(0),
147 "origin" => origin = Some(value.to_string()),
148 "accept" => accept = value.to_ascii_lowercase(),
149 "connection" => close = value.eq_ignore_ascii_case("close"),
150 _ => {}
151 }
152 }
153 if let Some(o) = &origin {
154 if !origin_ok(o) {
155 write_response(&mut stream, 403, "application/json", br#"{"error":"forbidden origin"}"#, true)?;
156 return Ok(());
157 }
158 }
159 if content_length > MAX_BODY {
160 write_response(&mut stream, 413, "application/json", b"", true)?;
162 return Ok(());
163 }
164 let mut body = vec![0u8; content_length];
165 reader.read_exact(&mut body)?;
166 match (method.as_str(), path == "/mcp") {
167 ("POST", true) => {
168 let reply = handle_post(&body, tx, ctx);
169 match reply {
170 None => write_response(&mut stream, 202, "", b"", close)?,
171 Some(v) => {
172 let json = v.to_string();
173 if accept.contains("text/event-stream")
175 && !accept.contains("application/json")
176 && !accept.contains("*/*")
177 {
178 let sse = format!("event: message\ndata: {json}\n\n");
179 write_response(&mut stream, 200, "text/event-stream", sse.as_bytes(), close)?;
180 } else {
181 write_response(&mut stream, 200, "application/json", json.as_bytes(), close)?;
182 }
183 }
184 }
185 }
186 ("GET", true) => write_response(&mut stream, 405, "application/json", b"", close)?,
187 ("DELETE", true) => write_response(&mut stream, 200, "application/json", b"", close)?, _ => write_response(&mut stream, 404, "application/json", b"", close)?,
189 }
190 if close {
191 return Ok(());
192 }
193 }
194}
195
196fn origin_ok(origin: &str) -> bool {
197 let host = origin.split("://").nth(1).unwrap_or(origin);
198 let host = host.split([':', '/']).next().unwrap_or("");
199 host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "[::1]"
200}
201
202fn session_id() -> &'static str {
203 static ID: std::sync::OnceLock<String> = std::sync::OnceLock::new();
204 ID.get_or_init(|| {
205 let ms = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);
206 format!("se-{:x}-{ms:x}", std::process::id())
207 })
208}
209
210fn write_response(
211 stream: &mut TcpStream,
212 status: u16,
213 content_type: &str,
214 body: &[u8],
215 close: bool,
216) -> std::io::Result<()> {
217 let reason = match status {
218 200 => "OK",
219 202 => "Accepted",
220 403 => "Forbidden",
221 404 => "Not Found",
222 405 => "Method Not Allowed",
223 413 => "Payload Too Large",
224 503 => "Service Unavailable",
225 _ => "",
226 };
227 let mut head =
228 format!("HTTP/1.1 {status} {reason}\r\nContent-Length: {}\r\nMcp-Session-Id: {}\r\n", body.len(), session_id());
229 if !content_type.is_empty() {
230 head.push_str("Content-Type: ");
231 head.push_str(content_type);
232 head.push_str("\r\n");
233 }
234 if close {
235 head.push_str("Connection: close\r\n");
236 }
237 head.push_str("\r\n");
238 stream.write_all(head.as_bytes())?;
239 stream.write_all(body)?;
240 stream.flush()
241}
242
243fn handle_post(body: &[u8], tx: &Sender<ToolCall>, ctx: &egui::Context) -> Option<Value> {
248 let parsed: Value = match serde_json::from_slice(body) {
249 Ok(v) => v,
250 Err(_) => {
251 return Some(json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32700, "message": "parse error"}}))
252 }
253 };
254 match parsed {
255 Value::Array(items) => {
256 let replies: Vec<Value> = items.iter().filter_map(|r| dispatch(r, tx, ctx)).collect();
257 if replies.is_empty() {
258 None
259 } else {
260 Some(Value::Array(replies))
261 }
262 }
263 single => dispatch(&single, tx, ctx),
264 }
265}
266
267fn dispatch(req: &Value, tx: &Sender<ToolCall>, ctx: &egui::Context) -> Option<Value> {
269 let id = req.get("id").cloned();
270 let method = req.get("method").and_then(Value::as_str).unwrap_or("");
271 if method.is_empty() {
272 return Some(rpc_err(id.unwrap_or(Value::Null), -32600, "invalid request".into()));
273 }
274 if method.starts_with("notifications/") {
275 return None; }
277 let id = id?; let params = req.get("params").cloned().unwrap_or_else(|| json!({}));
279 let result: Result<Value, (i64, String)> = match method {
280 "initialize" => {
281 let pv = params.get("protocolVersion").and_then(Value::as_str).unwrap_or("2025-06-18");
282 Ok(json!({
283 "protocolVersion": pv,
284 "capabilities": {"tools": {}, "resources": {}},
285 "serverInfo": {"name": "simple-editor", "version": env!("CARGO_PKG_VERSION")},
286 }))
287 }
288 "ping" => Ok(json!({})),
289 "tools/list" => Ok(json!({"tools": tools::list_json()})),
290 "tools/call" => match params.get("name").and_then(Value::as_str) {
291 None | Some("") => Err((-32602, "missing tool name".into())),
292 Some(name) => {
293 let args = params.get("arguments").cloned().unwrap_or_else(|| json!({}));
294 Ok(match call_tool(name, args, tx, ctx) {
295 Ok(v) => json!({"content": [{"type": "text", "text": v.to_string()}], "isError": false}),
296 Err(msg) => json!({"content": [{"type": "text", "text": msg}], "isError": true}),
297 })
298 }
299 },
300 "resources/list" => Ok(json!({"resources": [
301 {"uri": "simple-editor://project", "name": "project", "description": "Full project JSON (the .sedit document)", "mimeType": "application/json"},
302 {"uri": "simple-editor://style", "name": "style", "description": "Markdown style summary of the project", "mimeType": "text/markdown"},
303 {"uri": "simple-editor://notes", "name": "notes", "description": "Free-form project notes", "mimeType": "text/plain"},
304 ]})),
305 "resources/read" => {
306 let uri = params.get("uri").and_then(Value::as_str).unwrap_or("");
307 let (tool, mime) = match uri {
308 "simple-editor://project" => ("project.get", "application/json"),
309 "simple-editor://style" => ("style.summary", "text/markdown"),
310 "simple-editor://notes" => ("notes.get", "text/plain"),
311 _ => ("", ""),
312 };
313 if tool.is_empty() {
314 Err((-32602, format!("unknown resource: {uri}")))
315 } else {
316 match call_tool(tool, json!({}), tx, ctx) {
317 Ok(v) => {
318 let text = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string());
319 Ok(json!({"contents": [{"uri": uri, "mimeType": mime, "text": text}]}))
320 }
321 Err(msg) => Err((-32603, msg)),
322 }
323 }
324 }
325 _ => Err((-32601, format!("method not found: {method}"))),
326 };
327 Some(match result {
328 Ok(r) => json!({"jsonrpc": "2.0", "id": id, "result": r}),
329 Err((code, msg)) => rpc_err(id, code, msg),
330 })
331}
332
333fn rpc_err(id: Value, code: i64, message: String) -> Value {
334 json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})
335}
336
337fn call_tool(name: &str, args: Value, tx: &Sender<ToolCall>, ctx: &egui::Context) -> Result<Value, String> {
339 let (reply, rx) = mpsc::channel();
340 tx.send(ToolCall { name: name.to_string(), args, reply }).map_err(|_| "editor is shutting down".to_string())?;
341 ctx.request_repaint();
342 let timeout = match name {
343 "export.video" | "media.convert" => Duration::from_secs(30 * 60),
344 _ => Duration::from_secs(60),
345 };
346 rx.recv_timeout(timeout).map_err(|_| format!("{name}: timed out"))?
347}
348
349pub fn png_encode(frame: &crate::media::Frame) -> Vec<u8> {
354 let (w, h) = (frame.width as usize, frame.height as usize);
355 let stride = w * 4;
356 let mut raw = Vec::with_capacity((stride + 1) * h);
358 for row in frame.rgba.chunks_exact(stride).take(h) {
359 raw.push(0);
360 raw.extend_from_slice(row);
361 }
362 let mut z = Vec::with_capacity(raw.len() + raw.len() / 65535 * 5 + 16);
364 z.extend_from_slice(&[0x78, 0x01]);
365 if raw.is_empty() {
366 z.extend_from_slice(&[1, 0, 0, 0xff, 0xff]); } else {
368 let mut blocks = raw.chunks(65535).peekable();
369 while let Some(b) = blocks.next() {
370 z.push(blocks.peek().is_none() as u8); let len = b.len() as u16;
372 z.extend_from_slice(&len.to_le_bytes());
373 z.extend_from_slice(&(!len).to_le_bytes());
374 z.extend_from_slice(b);
375 }
376 }
377 z.extend_from_slice(&adler32(&raw).to_be_bytes());
378
379 let mut png = Vec::with_capacity(z.len() + 64);
380 png.extend_from_slice(&[137, 80, 78, 71, 13, 10, 26, 10]);
381 let mut ihdr = [0u8; 13];
382 ihdr[..4].copy_from_slice(&frame.width.to_be_bytes());
383 ihdr[4..8].copy_from_slice(&frame.height.to_be_bytes());
384 ihdr[8..].copy_from_slice(&[8, 6, 0, 0, 0]); write_chunk(&mut png, b"IHDR", &ihdr);
386 write_chunk(&mut png, b"IDAT", &z);
387 write_chunk(&mut png, b"IEND", &[]);
388 png
389}
390
391fn write_chunk(out: &mut Vec<u8>, typ: &[u8; 4], data: &[u8]) {
392 out.extend_from_slice(&(data.len() as u32).to_be_bytes());
393 out.extend_from_slice(typ);
394 out.extend_from_slice(data);
395 out.extend_from_slice(&crc32(typ, data).to_be_bytes());
396}
397
398fn adler32(data: &[u8]) -> u32 {
399 let (mut a, mut b) = (1u32, 0u32);
400 for chunk in data.chunks(5552) {
401 for &x in chunk {
402 a += x as u32;
403 b += a;
404 }
405 a %= 65521;
406 b %= 65521;
407 }
408 (b << 16) | a
409}
410
411const CRC_TABLE: [u32; 256] = {
412 let mut t = [0u32; 256];
413 let mut n = 0;
414 while n < 256 {
415 let mut c = n as u32;
416 let mut k = 0;
417 while k < 8 {
418 c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
419 k += 1;
420 }
421 t[n] = c;
422 n += 1;
423 }
424 t
425};
426
427fn crc32(a: &[u8], b: &[u8]) -> u32 {
428 let mut c = 0xFFFF_FFFFu32;
429 for &x in a.iter().chain(b) {
430 c = CRC_TABLE[((c ^ x as u32) & 0xff) as usize] ^ (c >> 8);
431 }
432 !c
433}
434
435#[cfg(test)]
438mod tests {
439 use super::*;
440
441 fn read_response(stream: &TcpStream) -> (u16, String) {
442 let mut reader = BufReader::new(stream.try_clone().unwrap());
443 let mut line = String::new();
444 reader.read_line(&mut line).unwrap();
445 let status: u16 = line.split_whitespace().nth(1).unwrap_or("0").parse().unwrap();
446 let mut len = 0usize;
447 loop {
448 let mut h = String::new();
449 reader.read_line(&mut h).unwrap();
450 if h.trim_end().is_empty() {
451 break;
452 }
453 if let Some(v) = h.to_ascii_lowercase().strip_prefix("content-length:") {
454 len = v.trim().parse().unwrap();
455 }
456 }
457 let mut body = vec![0u8; len];
458 reader.read_exact(&mut body).unwrap();
459 (status, String::from_utf8(body).unwrap())
460 }
461
462 fn post(stream: &mut TcpStream, body: &str) -> (u16, Value) {
463 let req = format!(
464 "POST /mcp HTTP/1.1\r\nHost: 127.0.0.1\r\nAccept: application/json\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
465 body.len(),
466 body
467 );
468 stream.write_all(req.as_bytes()).unwrap();
469 let (status, body) = read_response(stream);
470 let v = if body.is_empty() { Value::Null } else { serde_json::from_str(&body).unwrap() };
471 (status, v)
472 }
473
474 #[test]
475 fn server_end_to_end() {
476 let (server, rx) = Server::start(0, egui::Context::default()).unwrap();
477 let port = server.port();
478 assert_ne!(port, 0);
479 assert_eq!(server.url(), format!("http://127.0.0.1:{port}/mcp"));
480 let answerer = std::thread::spawn(move || {
482 for _ in 0..2 {
483 let Ok(call) = rx.recv_timeout(Duration::from_secs(10)) else { return };
484 let r = match call.name.as_str() {
485 "project.summary" => Ok(json!({"clips": 3})),
486 "notes.get" => Ok(json!("hello notes")),
487 other => Err(format!("unknown tool {other}")),
488 };
489 let _ = call.reply.send(r);
490 }
491 });
492 let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
493 s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
494
495 let (st, v) = post(
497 &mut s,
498 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}"#,
499 );
500 assert_eq!(st, 200);
501 assert_eq!(v["result"]["protocolVersion"], "2025-03-26");
502 assert_eq!(v["result"]["serverInfo"]["name"], "simple-editor");
503 assert!(v["result"]["capabilities"]["tools"].is_object());
504
505 let (st, v) = post(&mut s, r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#);
507 assert_eq!(st, 202);
508 assert_eq!(v, Value::Null);
509
510 let (st, v) = post(&mut s, r#"{"jsonrpc":"2.0","id":2,"method":"ping"}"#);
512 assert_eq!(st, 200);
513 assert!(v["result"].is_object());
514
515 let (st, v) = post(&mut s, r#"{"jsonrpc":"2.0","id":3,"method":"tools/list"}"#);
517 assert_eq!(st, 200);
518 let list = v["result"]["tools"].as_array().unwrap();
519 assert_eq!(list.len(), tools::TOOLS.len());
520 let summary = list.iter().find(|t| t["name"] == "project.summary").unwrap();
521 assert_eq!(summary["inputSchema"]["type"], "object");
522
523 let (st, v) = post(
525 &mut s,
526 r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"project.summary","arguments":{}}}"#,
527 );
528 assert_eq!(st, 200);
529 assert_eq!(v["result"]["isError"], false);
530 assert!(v["result"]["content"][0]["text"].as_str().unwrap().contains("clips"));
531
532 let (st, v) = post(
534 &mut s,
535 r#"{"jsonrpc":"2.0","id":5,"method":"resources/read","params":{"uri":"simple-editor://notes"}}"#,
536 );
537 assert_eq!(st, 200);
538 assert_eq!(v["result"]["contents"][0]["text"], "hello notes");
539
540 let (_, v) = post(&mut s, r#"{"jsonrpc":"2.0","id":6,"method":"resources/list"}"#);
542 assert_eq!(v["result"]["resources"].as_array().unwrap().len(), 3);
543
544 let (_, v) = post(&mut s, r#"{"jsonrpc":"2.0","id":7,"method":"nope"}"#);
546 assert_eq!(v["error"]["code"], -32601);
547 let (_, v) = post(&mut s, "not json");
548 assert_eq!(v["error"]["code"], -32700);
549
550 s.write_all(b"GET /mcp HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n").unwrap();
552 assert_eq!(read_response(&s).0, 405);
553 s.write_all(b"DELETE /mcp HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n").unwrap();
554 assert_eq!(read_response(&s).0, 200);
555
556 let mut evil = TcpStream::connect(("127.0.0.1", port)).unwrap();
558 evil.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
559 evil.write_all(
560 b"POST /mcp HTTP/1.1\r\nHost: 127.0.0.1\r\nOrigin: http://evil.com\r\nContent-Length: 2\r\n\r\n{}",
561 )
562 .unwrap();
563 assert_eq!(read_response(&evil).0, 403);
564
565 server.stop();
566 answerer.join().unwrap();
567 }
568
569 #[test]
570 fn command_line() {
571 assert_eq!(
572 claude_code_command(7337),
573 "claude mcp add --transport http simple-editor http://127.0.0.1:7337/mcp"
574 );
575 }
576
577 #[test]
578 fn png_signature_and_ffmpeg_roundtrip() {
579 let mut f = crate::media::Frame::new(200, 100);
581 for (i, b) in f.rgba.iter_mut().enumerate() {
582 *b = (i * 7 % 251) as u8;
583 }
584 let png = png_encode(&f);
585 assert_eq!(&png[..8], &[137, 80, 78, 71, 13, 10, 26, 10]);
586 let path = std::env::temp_dir().join(format!("se-mcp-png-{}.png", std::process::id()));
587 std::fs::write(&path, &png).unwrap();
588 let out = std::process::Command::new("ffmpeg")
589 .args(["-v", "error", "-i"])
590 .arg(&path)
591 .args(["-f", "rawvideo", "-pix_fmt", "rgba", "pipe:1"])
592 .output();
593 let _ = std::fs::remove_file(&path);
594 let Ok(out) = out else { return }; assert!(out.status.success(), "ffmpeg: {}", String::from_utf8_lossy(&out.stderr));
596 assert_eq!(out.stdout, f.rgba, "decoded RGBA differs");
597 }
598}