Skip to main content

snitch/
mcp.rs

1//! A local stdio MCP server, hand-rolled, with no async runtime.
2//!
3//! WHY NOT THE OFFICIAL SDK. `rmcp` is 59 crates and brings tokio, measured with `cargo tree` on
4//! 2026-08-30. MCP over stdio is newline-delimited JSON-RPC 2.0 read from one pipe and written to
5//! another, which is a blocking read loop and about two hundred lines against serde_json, which is
6//! already a dependency. The house rule is no async runtime for a loopback, single-operator
7//! process, and this is exactly that: one client, one request at a time, over a pipe.
8//!
9//! STDOUT IS THE PROTOCOL WIRE. Nothing in this module may print to it except a response frame.
10//! Every subprocess this tool spawns is captured with `Command::output()` rather than inherited,
11//! and the CLI's colour and banner code is never linked in here. Diagnostics go to stderr, which
12//! is where MCP hosts already look: Claude Desktop writes each server's stderr to
13//! `mcp-server-NAME.log` precisely because stdio servers use it for all their logging.
14//!
15//! NOTHING IS MUTATED IN PLACE. `exif::write_credit` edits the file it is given, so the credit
16//! tool copies to a new file first and edits the copy. A model calling a tool cannot be assumed to
17//! have understood that its input was about to be overwritten, so it never is.
18
19use std::io::{BufRead, Write};
20use std::path::{Path, PathBuf};
21
22use serde_json::{Map, Value, json};
23
24use crate::{exif, inspect, strip, text};
25
26/// The protocol revisions this server can speak, oldest first.
27///
28/// THE HANDSHAKE DOES NOT NEGOTIATE THE NEWEST VERSION. `2026-07-28` exists and is what the
29/// official SDK calls its latest, but it is adopted AFTER initialize through a separate
30/// mechanism; the `initialize` exchange itself only settles one of these. Answering the handshake
31/// with `2026-07-28` makes a conforming client refuse the session outright with "unsupported
32/// protocol version from the server", which is exactly what happened here before this list
33/// existed and is why it is a list and not a constant.
34pub const HANDSHAKE_VERSIONS: [&str; 4] = ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"];
35
36/// What this server answers with when the client asks for something it does not know.
37pub const PROTOCOL_VERSION: &str = "2025-11-25";
38
39/// Echo the client's version when it is one we speak, otherwise state ours and let the client
40/// decide whether it can proceed. That is what the specification asks for, in that order.
41pub fn negotiate(requested: Option<&str>) -> &'static str {
42    match requested {
43        Some(asked) => HANDSHAKE_VERSIONS
44            .iter()
45            .find(|known| **known == asked)
46            .copied()
47            .unwrap_or(PROTOCOL_VERSION),
48        None => PROTOCOL_VERSION,
49    }
50}
51
52pub const SUPPORTED_STRIP: &str =
53    "JPEG and PNG only. WebP, HEIC and AVIF are not supported for lossless stripping.";
54
55/// A refusal the caller should see as a sentence, not as a crash.
56#[derive(Debug)]
57pub struct ToolError(pub String);
58
59impl From<String> for ToolError {
60    fn from(s: String) -> Self {
61        ToolError(s)
62    }
63}
64
65impl From<&str> for ToolError {
66    fn from(s: &str) -> Self {
67        ToolError(s.to_string())
68    }
69}
70
71type ToolResult = Result<Value, ToolError>;
72
73pub fn log(message: &str) {
74    eprintln!("snitch-mcp: {message}");
75    let _ = std::io::stderr().flush();
76}
77
78// ----------------------------------------------------------------------------------------------
79// guards
80// ----------------------------------------------------------------------------------------------
81
82/// Resolve an input path, or refuse it. Ordinary readable files only.
83pub fn source_path(raw: &str) -> Result<PathBuf, ToolError> {
84    if raw.trim().is_empty() {
85        return Err("no path given".into());
86    }
87    let expanded = expand_home(raw);
88    if !expanded.exists() {
89        return Err(format!("{}: not found", expanded.display()).into());
90    }
91    // Following a link to read is fine. Writing through one is how a caller destroys something it
92    // did not name, so the whole class is refused rather than special-cased per tool.
93    if expanded
94        .symlink_metadata()
95        .map(|m| m.file_type().is_symlink())
96        .unwrap_or(false)
97    {
98        return Err(format!(
99            "{}: is a symbolic link. Pass the real file.",
100            expanded.display()
101        )
102        .into());
103    }
104    if !expanded.is_file() {
105        return Err(format!("{}: not a regular file", expanded.display()).into());
106    }
107    Ok(expanded)
108}
109
110/// Resolve an output path that cannot be the source and cannot be silently clobbered.
111pub fn target_path(
112    source: &Path,
113    out: &str,
114    suffix: &str,
115    force: bool,
116) -> Result<PathBuf, ToolError> {
117    let target = if out.trim().is_empty() {
118        strip::outpath(source, None, suffix)
119    } else {
120        expand_home(out)
121    };
122    if target.is_dir() {
123        return Err(format!("{}: is a directory", target.display()).into());
124    }
125    if strip::same_file(source, &target) {
126        return Err("output would overwrite the source. Give a different out_path.".into());
127    }
128    if target.exists() && !force {
129        return Err(format!(
130            "{}: exists. Pass force=true to replace it.",
131            target.display()
132        )
133        .into());
134    }
135    let parent = target.parent().filter(|p| !p.as_os_str().is_empty());
136    match parent {
137        Some(parent) if !parent.is_dir() => {
138            Err(format!("{}: output directory does not exist", parent.display()).into())
139        }
140        _ => Ok(target),
141    }
142}
143
144fn expand_home(raw: &str) -> PathBuf {
145    if let Some(rest) = raw.strip_prefix("~/") {
146        if let Some(home) = std::env::var_os("HOME") {
147            return PathBuf::from(home).join(rest);
148        }
149    }
150    PathBuf::from(raw)
151}
152
153/// Name what actually went, by comparing the two reports rather than by assuming.
154fn removed_classes(before: &inspect::Report, after: &inspect::Report) -> Vec<&'static str> {
155    let mut gone = Vec::new();
156    if !before.gps.is_empty() && after.gps.is_empty() {
157        gone.push("location");
158    }
159    if !before.camera.is_empty() && after.camera.is_empty() {
160        gone.push("camera");
161    }
162    if !before.credit.is_empty() && after.credit.is_empty() {
163        gone.push("credit");
164    }
165    if !before.png_text.is_empty() && after.png_text.is_empty() {
166        gone.push("png text chunks");
167    }
168    let had = matches!(
169        before.c2pa_status,
170        crate::c2pa::Status::Present | crate::c2pa::Status::DetectedUnverified
171    );
172    if had && after.c2pa_status == crate::c2pa::Status::Absent {
173        gone.push("C2PA content credential");
174    }
175    gone
176}
177
178// ----------------------------------------------------------------------------------------------
179// the tools
180// ----------------------------------------------------------------------------------------------
181
182fn arg_str(args: &Map<String, Value>, key: &str) -> String {
183    args.get(key)
184        .and_then(Value::as_str)
185        .unwrap_or("")
186        .to_string()
187}
188
189fn arg_bool(args: &Map<String, Value>, key: &str) -> bool {
190    args.get(key).and_then(Value::as_bool).unwrap_or(false)
191}
192
193fn optional(value: String) -> Option<String> {
194    (!value.is_empty()).then_some(value)
195}
196
197fn snitch_inspect(args: &Map<String, Value>) -> ToolResult {
198    let path = source_path(&arg_str(args, "path"))?;
199    let report = inspect::inspect(&path, None).map_err(ToolError)?;
200    let summary = inspect::c2pa_summary(&report);
201    let mut value = serde_json::to_value(&report).map_err(|e| ToolError(e.to_string()))?;
202    value["c2pa_summary"] = serde_json::to_value(summary).map_err(|e| ToolError(e.to_string()))?;
203    Ok(value)
204}
205
206fn snitch_strip_metadata(args: &Map<String, Value>) -> ToolResult {
207    let path = source_path(&arg_str(args, "path"))?;
208    let before = inspect::inspect(&path, None).map_err(ToolError)?;
209    let target = target_path(
210        &path,
211        &arg_str(args, "out_path"),
212        "-clean",
213        arg_bool(args, "force"),
214    )?;
215    let (removed, identical) = strip::strip_atomic(&path, &target).map_err(ToolError)?;
216    let after = inspect::inspect(&target, None).map_err(ToolError)?;
217    Ok(json!({
218        "output_path": target.display().to_string(),
219        "format": after.mime_type,
220        "bytes_before": before.bytes,
221        "bytes_after": after.bytes,
222        "bytes_removed": removed,
223        "removed": removed_classes(&before, &after),
224        "pixels_identical": identical,
225        "proof": "Decoded pixels compared by SHA-256 before and after. This is a per-run check, \
226                  not a claim about the algorithm.",
227        "watermarks": "In-pixel watermarks are untouched. Metadata stripping cannot reach them.",
228    }))
229}
230
231fn snitch_add_credit(args: &Map<String, Value>) -> ToolResult {
232    let path = source_path(&arg_str(args, "path"))?;
233    let credit = exif::Credit {
234        creator: optional(arg_str(args, "creator")),
235        credit: optional(arg_str(args, "credit")),
236        copyright: optional(arg_str(args, "copyright")),
237        terms: optional(arg_str(args, "terms")),
238        rights_url: optional(arg_str(args, "rights_url")),
239        contact: optional(arg_str(args, "contact")),
240        title: optional(arg_str(args, "title")),
241        description: optional(arg_str(args, "description")),
242        ..Default::default()
243    };
244    if credit.is_empty() {
245        return Err(
246            "nothing to write: give at least one of creator, credit, copyright, terms, \
247                    rights_url, contact, title or description"
248                .into(),
249        );
250    }
251    let keep_gps = arg_bool(args, "keep_gps");
252    let target = target_path(
253        &path,
254        &arg_str(args, "out_path"),
255        "-credited",
256        arg_bool(args, "force"),
257    )?;
258    let bytes_before = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
259
260    // Copy first, then edit the copy. write_credit mutates whatever file it is handed, and the
261    // caller's input is not ours to change.
262    let temporary = strip::temporary_sibling(&target, true).map_err(ToolError)?;
263    let result = (|| -> Result<(), String> {
264        std::fs::copy(&path, &temporary).map_err(|e| format!("{}: {e}", temporary.display()))?;
265        exif::write_credit(&temporary, &credit, !keep_gps)?;
266        std::fs::rename(&temporary, &target).map_err(|e| format!("{}: {e}", target.display()))
267    })();
268    if let Err(e) = result {
269        let _ = std::fs::remove_file(&temporary);
270        return Err(ToolError(e));
271    }
272
273    let after = inspect::inspect(&target, None).map_err(ToolError)?;
274    Ok(json!({
275        "output_path": target.display().to_string(),
276        "format": after.mime_type,
277        "bytes_before": bytes_before,
278        "bytes_after": after.bytes,
279        "written": after.credit,
280        "gps_kept": !after.gps.is_empty(),
281        "note": "Written into metadata, which survives ordinary copying but is removed by many \
282                 upload paths. A visible stamp is the version that survives stripping.",
283    }))
284}
285
286fn snitch_verify_c2pa(args: &Map<String, Value>) -> ToolResult {
287    let path = source_path(&arg_str(args, "path"))?;
288    let report = inspect::inspect(&path, None).map_err(ToolError)?;
289    let summary = inspect::c2pa_summary(&report);
290    let mut value = serde_json::to_value(summary).map_err(|e| ToolError(e.to_string()))?;
291    value["file"] = json!(report.file);
292    Ok(value)
293}
294
295fn snitch_clean_text(args: &Map<String, Value>) -> ToolResult {
296    let input = arg_str(args, "text_input");
297    serde_json::to_value(text::clean(&input)).map_err(|e| ToolError(e.to_string()))
298}
299
300struct Tool {
301    name: &'static str,
302    description: &'static str,
303    schema: fn() -> Value,
304    run: fn(&Map<String, Value>) -> ToolResult,
305}
306
307fn path_only() -> Value {
308    json!({
309        "type": "object",
310        "properties": {"path": {"type": "string", "description": "Absolute path to the image file."}},
311        "required": ["path"],
312    })
313}
314
315fn tools() -> Vec<Tool> {
316    vec![
317        Tool {
318            name: "snitch_inspect",
319            description: "Report everything an image file is saying about its owner: GPS location, \
320                          camera, credit and copyright, PNG text chunks a generator wrote, and any \
321                          C2PA content credential. Reads the file, never changes it.",
322            schema: path_only,
323            run: snitch_inspect,
324        },
325        Tool {
326            name: "snitch_strip_metadata",
327            description: concat!(
328                "Remove metadata from an image without re-encoding it, so the decoded pixels come \
329                 out byte-identical. ",
330                "JPEG and PNG only. WebP, HEIC and AVIF are not supported for lossless stripping. ",
331                "Writes a new file and leaves the input untouched. Does not remove in-pixel \
332                 watermarks such as SynthID: those live in the image data and no metadata tool can \
333                 reach them.",
334            ),
335            schema: || {
336                json!({
337                    "type": "object",
338                    "properties": {
339                        "path": {"type": "string", "description": "Absolute path to the image file."},
340                        "out_path": {"type": "string", "description": "Where to write. Defaults to the input with a -clean suffix."},
341                        "force": {"type": "boolean", "description": "Replace out_path if it already exists."},
342                    },
343                    "required": ["path"],
344                })
345            },
346            run: snitch_strip_metadata,
347        },
348        Tool {
349            name: "snitch_add_credit",
350            description: "Write creator, credit, copyright and licence into an image's IPTC and \
351                          XMP fields, into a new file. Location data is dropped unless keep_gps is \
352                          true, because publishing coordinates deanonymises people. Does not sign \
353                          a C2PA credential and does not stamp the pixels.",
354            schema: || {
355                json!({
356                    "type": "object",
357                    "properties": {
358                        "path": {"type": "string", "description": "Absolute path to the image file."},
359                        "creator": {"type": "string", "description": "The person who made it."},
360                        "credit": {"type": "string", "description": "The studio or organisation to credit."},
361                        "copyright": {"type": "string"},
362                        "terms": {"type": "string", "description": "Usage terms."},
363                        "rights_url": {"type": "string"},
364                        "contact": {"type": "string", "description": "Email or URL for licensing enquiries."},
365                        "title": {"type": "string"},
366                        "description": {"type": "string"},
367                        "keep_gps": {"type": "boolean", "description": "Keep location data. Off by default, because it doxxes people."},
368                        "out_path": {"type": "string"},
369                        "force": {"type": "boolean"},
370                    },
371                    "required": ["path"],
372                })
373            },
374            run: snitch_add_credit,
375        },
376        Tool {
377            name: "snitch_verify_c2pa",
378            description: "Check an image's C2PA content credential. Reports presence, \
379                          asset-binding integrity and signer trust as three separate answers, \
380                          because they are three different facts. A missing credential is not \
381                          evidence that an image is real, and a self-signed certificate is not \
382                          proof of who signed it.",
383            schema: path_only,
384            run: snitch_verify_c2pa,
385        },
386        Tool {
387            name: "snitch_clean_text",
388            description: "Find hidden characters in text: invisible zero-width marks, \
389                          right-to-left overrides, letters from mixed alphabets, unusual spaces \
390                          and word-processor substitutions. Returns the text with the tracking \
391                          characters removed. Never touches ZWJ or ZWNJ, which emoji sequences and \
392                          Arabic, Persian, Kurdish and Indic writing depend on.",
393            schema: || {
394                json!({
395                    "type": "object",
396                    "properties": {"text_input": {"type": "string", "description": "The text to check."}},
397                    "required": ["text_input"],
398                })
399            },
400            run: snitch_clean_text,
401        },
402    ]
403}
404
405/// The tool list as the protocol wants it. Public so a test can assert the descriptions without
406/// opening a session.
407pub fn tool_descriptors() -> Vec<Value> {
408    tools()
409        .into_iter()
410        .map(|t| {
411            json!({
412                "name": t.name,
413                "description": t.description,
414                "inputSchema": (t.schema)(),
415            })
416        })
417        .collect()
418}
419
420pub fn call_tool(name: &str, args: &Map<String, Value>) -> ToolResult {
421    match tools().into_iter().find(|t| t.name == name) {
422        Some(tool) => (tool.run)(args),
423        None => Err(format!("unknown tool: {name}").into()),
424    }
425}
426
427// ----------------------------------------------------------------------------------------------
428// JSON-RPC 2.0 over stdio
429// ----------------------------------------------------------------------------------------------
430
431fn result(id: Value, value: Value) -> Value {
432    json!({"jsonrpc": "2.0", "id": id, "result": value})
433}
434
435fn error(id: Value, code: i64, message: &str) -> Value {
436    json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})
437}
438
439/// Handle one parsed request. `None` means it was a notification and gets no reply.
440pub fn handle(request: &Value) -> Option<Value> {
441    let method = request.get("method").and_then(Value::as_str).unwrap_or("");
442    let id = request.get("id").cloned();
443    let params = request.get("params").cloned().unwrap_or_else(|| json!({}));
444
445    // A notification has no id. Answering one is a protocol violation, so it never happens here.
446    let id = id?;
447
448    match method {
449        "initialize" => Some(result(
450            id,
451            json!({
452                "protocolVersion": negotiate(
453                    params.get("protocolVersion").and_then(Value::as_str)
454                ),
455                "capabilities": {"tools": {"listChanged": false}},
456                "serverInfo": {"name": "snitch", "version": crate::VERSION},
457                "instructions": "Read, strip and credit image metadata, and check text for hidden \
458                                 characters. Everything runs locally. Nothing is uploaded and no \
459                                 network call is made.",
460            }),
461        )),
462        "ping" => Some(result(id, json!({}))),
463        "tools/list" => Some(result(id, json!({"tools": tool_descriptors()}))),
464        "tools/call" => {
465            let name = params.get("name").and_then(Value::as_str).unwrap_or("");
466            let empty = Map::new();
467            let args = params
468                .get("arguments")
469                .and_then(Value::as_object)
470                .unwrap_or(&empty);
471            match call_tool(name, args) {
472                Ok(value) => {
473                    let text = serde_json::to_string_pretty(&value).unwrap_or_default();
474                    Some(result(
475                        id,
476                        json!({
477                            "content": [{"type": "text", "text": text}],
478                            "structuredContent": value,
479                            "isError": false,
480                        }),
481                    ))
482                }
483                // A refusal is a tool result with isError, not a protocol error. The distinction
484                // matters: a protocol error tells the host the server is broken, and declining to
485                // overwrite somebody's file is the server working exactly as intended.
486                Err(ToolError(message)) => Some(result(
487                    id,
488                    json!({
489                        "content": [{"type": "text", "text": message}],
490                        "isError": true,
491                    }),
492                )),
493            }
494        }
495        other => Some(error(id, -32601, &format!("method not found: {other}"))),
496    }
497}
498
499/// The blocking read loop. One line in, at most one line out, until stdin closes.
500pub fn serve() -> std::io::Result<()> {
501    log(&format!("serving on stdio, snitch {}", crate::VERSION));
502    let stdin = std::io::stdin();
503    let mut stdout = std::io::stdout().lock();
504    for line in stdin.lock().lines() {
505        let line = line?;
506        if line.trim().is_empty() {
507            continue;
508        }
509        let response = match serde_json::from_str::<Value>(&line) {
510            Ok(request) => handle(&request),
511            Err(e) => Some(error(Value::Null, -32700, &format!("parse error: {e}"))),
512        };
513        if let Some(response) = response {
514            writeln!(
515                stdout,
516                "{}",
517                serde_json::to_string(&response).unwrap_or_default()
518            )?;
519            stdout.flush()?;
520        }
521    }
522    Ok(())
523}