1use std::io::{BufRead, Write};
20use std::path::{Path, PathBuf};
21
22use serde_json::{Map, Value, json};
23
24use crate::{exif, inspect, strip, text};
25
26pub const HANDSHAKE_VERSIONS: [&str; 4] = ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"];
35
36pub const PROTOCOL_VERSION: &str = "2025-11-25";
38
39pub 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#[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
78pub 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 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
110pub 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
153fn 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
178fn 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 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
405pub 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
427fn 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
439pub 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 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 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
499pub 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}