simple_editor\mcp/
mod.rs

1//! MCP server so AI agents (Claude Code etc.) can co-edit live: Streamable HTTP transport (JSON-RPC 2.0
2//! over HTTP POST /mcp on 127.0.0.1:<port>, no external crates — a tiny HTTP/1.1 parser on std TcpListener),
3//! toggled in Settings. The server thread handles `initialize`, `ping`, `tools/list`, `tools/call`,
4//! `resources/list`, `resources/read` (project json, style summary, notes) and answers JSON
5//! (no SSE stream needed for request/response). Tool calls are forwarded to the UI thread as `ToolCall`s
6//! (the App executes them on the next frame against the live project, with undo, and replies through the
7//! oneshot sender); `ctx.request_repaint()` wakes the UI. Tool definitions (names, descriptions, JSON schemas)
8//! live in `tools.rs` — the App matches on the same names.
9//!
10//! Connect from Claude Code:  `claude mcp add --transport http simple-editor http://127.0.0.1:7337/mcp`
11
12pub 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
23/// One tool invocation forwarded to the UI thread; reply with Ok(result json) or Err(message).
24pub 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    /// Bind 127.0.0.1:port and start the server thread. Returns the server handle and the receiver the UI
41    /// thread polls every frame (`try_recv` in a loop). Err if the port is busy. Port 0 = ephemeral (see `port()`).
42    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    /// The actual bound port (differs from the requested one when starting on port 0).
84    #[cfg(test)]
85    pub fn port(&self) -> u16 {
86        self.port
87    }
88
89    /// Stop accepting connections (the thread exits after the current request).
90    pub fn stop(self) {
91        self.stop.store(true, Ordering::Relaxed);
92        // Unblock accept() so the thread sees the flag and exits.
93        let _ = TcpStream::connect(("127.0.0.1", self.port));
94    }
95}
96
97/// The `claude mcp add` command line shown in the settings UI.
98pub 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
102// ---------------------------------------------------------------------------
103// HTTP
104
105fn 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        // Request line (tolerate a leading empty line per RFC 9112).
118        let mut line = String::new();
119        if reader.read_line(&mut line)? == 0 {
120            return Ok(()); // EOF: client closed
121        }
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        // Headers (case-insensitive names).
130        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            // Can't cheaply skip a huge body; refuse and drop the connection.
161            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                        // A client that only accepts SSE gets the response as a single event.
174                        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)?, // session end
188            _ => 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
243// ---------------------------------------------------------------------------
244// JSON-RPC
245
246/// Handle a POST body (single request or batch). None = nothing to send back (notifications only) → 202.
247fn 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
267/// One JSON-RPC request → response (None for notifications).
268fn 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; // e.g. notifications/initialized — acknowledged with 202, no body
276    }
277    let id = id?; // no id = notification: nothing to answer
278    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
337/// Forward one tool call to the UI thread and wait for the reply.
338fn 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
349// ---------------------------------------------------------------------------
350// PNG (store-only zlib — no deps)
351
352/// Encode an RGBA frame as PNG (store-only zlib) for the `render.frame` tool.
353pub 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    // Filtered scanlines: filter byte 0 (None) + row.
357    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    // zlib: header + deflate stored blocks (<= 65535 bytes each) + adler32 of the raw data.
363    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]); // final empty stored block
367    } 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); // BFINAL, BTYPE=00 (stored)
371            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]); // 8-bit RGBA, deflate, no interlace
385    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// ---------------------------------------------------------------------------
436
437#[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        // UI-thread stand-in: answer two tool calls.
481        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        // initialize (echoes the client's protocol version)
496        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        // notifications/initialized -> 202, empty body
506        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        // ping
511        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        // tools/list: one entry per TOOLS row, with schemas
516        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        // tools/call project.summary
524        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        // resources/read notes -> forwarded as notes.get
533        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        // resources/list
541        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        // unknown method -> -32601; parse error -> -32700
545        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        // GET -> 405, DELETE -> 200 (same keep-alive connection)
551        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        // Non-localhost Origin -> 403 (fresh connection; the server closes it)
557        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        // 200x100 -> raw stream > 65535 bytes: exercises multi-block stored zlib.
580        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 }; // no ffmpeg on PATH: skip
595        assert!(out.status.success(), "ffmpeg: {}", String::from_utf8_lossy(&out.stderr));
596        assert_eq!(out.stdout, f.rgba, "decoded RGBA differs");
597    }
598}