use std::io::{BufRead, Write};
use anyhow::Result;
use serde_json::{Map, Value, json};
use crate::capture::CaptureProvider;
const PROTOCOL_VERSIONS: [&str; 2] = ["2026-07-28", "2025-11-25"];
const META_PROTOCOL_VERSION: &str = "io.modelcontextprotocol/protocolVersion";
const META_SERVER_INFO: &str = "io.modelcontextprotocol/serverInfo";
const ERR_UNSUPPORTED_PROTOCOL: i64 = -32022;
const ERR_PARSE: i64 = -32700;
const ERR_INVALID_REQUEST: i64 = -32600;
const ERR_METHOD_NOT_FOUND: i64 = -32601;
const ERR_INVALID_PARAMS: i64 = -32602;
const TOOLS_TTL_MS: u64 = 3_600_000;
const MAX_WAIT_SECS: u64 = 120;
pub fn serve<P: CaptureProvider>(provider: &P) -> Result<()> {
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
for line in stdin.lock().lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let Some(response) = handle_line(provider, &line) else {
continue;
};
writeln!(stdout, "{response}")?;
stdout.flush()?;
}
Ok(())
}
fn handle_line<P: CaptureProvider>(provider: &P, line: &str) -> Option<String> {
let request: Value = match serde_json::from_str(line) {
Ok(value) => value,
Err(e) => return Some(error_response(&Value::Null, ERR_PARSE, &format!("{e}"))),
};
let id = request.get("id").cloned().unwrap_or(Value::Null);
if id.is_null() {
return None;
}
if request.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
return Some(error_response(
&id,
ERR_INVALID_REQUEST,
"every request needs \"jsonrpc\": \"2.0\"",
));
}
let Some(method) = request.get("method").and_then(Value::as_str) else {
return Some(error_response(&id, ERR_INVALID_REQUEST, "no method"));
};
let params = request.get("params");
if let Some(version) = params
.and_then(|p| p.get("_meta"))
.and_then(|m| m.get(META_PROTOCOL_VERSION))
.and_then(Value::as_str)
&& !PROTOCOL_VERSIONS.contains(&version)
{
return Some(error_response(
&id,
ERR_UNSUPPORTED_PROTOCOL,
&format!(
"protocol version {version} is not supported; this server speaks {}",
PROTOCOL_VERSIONS.join(", ")
),
));
}
let outcome = match method {
"server/discover" => Ok(discover()),
"initialize" => Ok(initialize()),
"tools/list" => Ok(tools_list()),
"tools/call" => call_tool(provider, params),
other => {
return Some(error_response(
&id,
ERR_METHOD_NOT_FOUND,
&format!("unknown method {other}"),
));
}
};
Some(match outcome {
Ok(result) => success_response(&id, result),
Err(message) => error_response(&id, ERR_INVALID_PARAMS, &message),
})
}
fn server_info() -> Value {
json!({
"name": "pixelcoords",
"title": "pixelcoords",
"version": env!("CARGO_PKG_VERSION"),
})
}
fn success_response(id: &Value, mut result: Value) -> String {
if let Some(object) = result.as_object_mut() {
object.insert("resultType".into(), json!("complete"));
let meta = object
.entry("_meta")
.or_insert_with(|| Value::Object(Map::new()));
if let Some(meta) = meta.as_object_mut() {
meta.insert(META_SERVER_INFO.into(), server_info());
}
}
json!({ "jsonrpc": "2.0", "id": id, "result": result }).to_string()
}
fn error_response(id: &Value, code: i64, message: &str) -> String {
json!({
"jsonrpc": "2.0",
"id": id,
"error": { "code": code, "message": message },
})
.to_string()
}
fn capabilities() -> Value {
json!({ "tools": {} })
}
fn discover() -> Value {
json!({
"protocolVersions": PROTOCOL_VERSIONS,
"capabilities": capabilities(),
"serverInfo": server_info(),
})
}
fn initialize() -> Value {
json!({
"protocolVersion": PROTOCOL_VERSIONS[0],
"capabilities": capabilities(),
"serverInfo": server_info(),
})
}
const TOOLS: [&str; 6] = [
"pixelcoords_sessions",
"pixelcoords_resolve",
"pixelcoords_assert",
"pixelcoords_wait",
"pixelcoords_find",
"pixelcoords_diff",
];
fn session_arg() -> Value {
json!({
"type": "string",
"description":
"Path to a session directory or its session.json, as returned by \
pixelcoords_sessions.",
})
}
fn label_arg(what: &str) -> Value {
json!({
"type": "string",
"description": format!(
"Restrict to regions with this label, case-insensitive. Omit for {what}. \
Labels come from pixelcoords_sessions.",
),
})
}
fn tool_schema(name: &str) -> Value {
match name {
"pixelcoords_sessions" => schema_sessions(),
"pixelcoords_resolve" => schema_resolve(),
"pixelcoords_assert" => schema_assert(),
"pixelcoords_wait" => schema_wait(),
"pixelcoords_find" => schema_find(),
"pixelcoords_diff" => schema_diff(),
_ => Value::Null,
}
}
fn schema_sessions() -> Value {
json!({
"name": "pixelcoords_sessions",
"title": "List saved sessions",
"description":
"List the sessions on this machine, newest first, with the labels each one \
holds. Start here: every other tool needs a session, and a session is a set \
of screen regions a human marked in the pixelcoords overlay. This tool \
cannot create one — if nothing is listed, ask the user to run `pixelcoords` \
and mark the regions they care about. Does not capture the screen.",
"inputSchema": {
"type": "object",
"properties": {
"root": {
"type": "string",
"description":
"Directory to search. Defaults to the captures folder \
pixelcoords saves to.",
},
},
"additionalProperties": false,
},
})
}
fn schema_resolve() -> Value {
json!({
"name": "pixelcoords_resolve",
"title": "Where to click now",
"description":
"Return the click point for each marked region, in the units this platform's \
input APIs expect. This is the tool to reach for by default: it reads a file \
and answers in microseconds, sends no image, and needs no screen-recording \
permission. Set relocate only when the UI may have moved since the session \
was saved — that captures the screen and costs far more.",
"inputSchema": {
"type": "object",
"properties": {
"session": session_arg(),
"label": label_arg("every region"),
"space": {
"type": "string",
"enum": ["global", "monitor", "window"],
"description":
"Which origin the answer is measured from. Default global, the \
whole-desktop grid most input APIs use.",
},
"units": {
"type": "string",
"enum": ["auto", "physical", "logical"],
"description":
"Default auto: logical points on macOS, physical pixels on \
Windows and X11 — what the platform's input APIs take.",
},
"relocate": {
"type": "boolean",
"description":
"Search the live screen for each region first, and answer where it \
is now. Captures the screen. Default false.",
},
},
"required": ["session"],
"additionalProperties": false,
},
})
}
fn schema_assert() -> Value {
json!({
"name": "pixelcoords_assert",
"title": "Did this point land in the right region",
"description":
"Score a point against the marked regions: which one it landed in, and \
whether that was the expected one. Use it to confirm a click went where you \
meant before acting on the result. A miss is an answer, not a failure — the \
result says ok false and names the region the point actually hit. Does not \
capture the screen.",
"inputSchema": {
"type": "object",
"properties": {
"session": session_arg(),
"point": {
"type": "string",
"description": "The point to score, as \"x,y\".",
},
"points": {
"type": "array",
"items": { "type": "string" },
"description":
"Score a whole trajectory in one call, one \"x,y\" per entry. \
Reads the session once instead of once per point.",
},
"expect": {
"type": "string",
"description":
"The label the point should land in. Omit to ask only which \
region it hit.",
},
"space": {
"type": "string",
"enum": ["global", "monitor", "window"],
"description": "Which origin the point is measured from. Default global.",
},
"monitor": {
"type": "integer",
"description": "Which monitor, when space is monitor.",
},
},
"required": ["session"],
"additionalProperties": false,
},
})
}
fn schema_wait() -> Value {
json!({
"name": "pixelcoords_wait",
"title": "Block until the screen settles",
"description":
"Poll until a marked region matches its saved appearance again, or stops \
matching. Use it instead of taking screenshots in a loop to find out whether \
a dialog has appeared or a spinner has finished. Captures the screen on every \
poll and blocks until it resolves or the timeout runs out; a timeout is an \
answer, reported as ok false.",
"inputSchema": {
"type": "object",
"properties": {
"session": session_arg(),
"label": label_arg("every region"),
"condition": {
"type": "string",
"enum": ["match", "change"],
"description":
"match waits for the region to look like its saved crop again; \
change waits for it to stop. Default match.",
},
"timeout_secs": {
"type": "integer",
"minimum": 1,
"maximum": MAX_WAIT_SECS,
"description": format!(
"How long to wait, in seconds. Default 30, maximum {MAX_WAIT_SECS}. \
The server answers nothing else while this runs.",
),
},
"interval_ms": {
"type": "integer",
"minimum": 1,
"description": "Milliseconds between polls. Default 500.",
},
"min_score": {
"type": "number",
"description":
"Match threshold, 0 to 1. Default 0.9. Lower it when something \
small moves inside the region on its own, such as a blinking \
text cursor, which otherwise reads as a change.",
},
},
"required": ["session"],
"additionalProperties": false,
},
})
}
fn schema_find() -> Value {
json!({
"name": "pixelcoords_find",
"title": "Where did this region move to",
"description":
"Search the live screen for each marked region and report where it is now, \
with the offset from where it was saved. Use it when the UI has drifted — a \
resized window, a scrolled page — and the saved coordinates no longer land. \
Captures the screen and is the most expensive tool here; prefer \
pixelcoords_resolve when nothing has moved.",
"inputSchema": {
"type": "object",
"properties": {
"session": session_arg(),
"label": label_arg("every region"),
},
"required": ["session"],
"additionalProperties": false,
},
})
}
fn schema_diff() -> Value {
json!({
"name": "pixelcoords_diff",
"title": "Do these regions still look right",
"description":
"Compare each marked region against the screen now, and report how much of it \
changed. Scoped to the regions a human marked rather than whole screenshots, \
so unrelated movement elsewhere is not a difference. Captures the screen \
unless against names stored images. Over-tolerance is an answer, reported as \
ok false.",
"inputSchema": {
"type": "object",
"properties": {
"session": session_arg(),
"label": label_arg("every region"),
"against": {
"type": "string",
"description":
"Directory of stored screenshots to compare against instead of the \
live screen. Avoids capturing.",
},
"tolerance": {
"type": "number",
"description":
"Percent of a region's pixels allowed to differ. Default 0, exact.",
},
},
"required": ["session"],
"additionalProperties": false,
},
})
}
fn tools_list() -> Value {
json!({
"tools": TOOLS.iter().map(|name| tool_schema(name)).collect::<Vec<_>>(),
"ttlMs": TOOLS_TTL_MS,
"cacheScope": "public",
})
}
fn arg_str<'a>(args: &'a Map<String, Value>, key: &str) -> Result<&'a str, String> {
match args.get(key) {
Some(Value::String(s)) => Ok(s),
Some(other) => Err(format!("{key} must be a string, got {other}")),
None => Err(format!("{key} is required")),
}
}
fn opt_str<'a>(args: &'a Map<String, Value>, key: &str) -> Result<Option<&'a str>, String> {
match args.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => Ok(Some(s)),
Some(other) => Err(format!("{key} must be a string, got {other}")),
}
}
fn opt_bool(args: &Map<String, Value>, key: &str) -> Result<Option<bool>, String> {
match args.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::Bool(b)) => Ok(Some(*b)),
Some(other) => Err(format!("{key} must be a boolean, got {other}")),
}
}
fn opt_u64(args: &Map<String, Value>, key: &str) -> Result<Option<u64>, String> {
match args.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::Number(n)) => n
.as_u64()
.map(Some)
.ok_or_else(|| format!("{key} must be a positive whole number, got {n}")),
Some(other) => Err(format!("{key} must be a number, got {other}")),
}
}
fn opt_f64(args: &Map<String, Value>, key: &str) -> Result<Option<f64>, String> {
match args.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::Number(n)) => Ok(n.as_f64()),
Some(other) => Err(format!("{key} must be a number, got {other}")),
}
}
fn reject_unknown(args: &Map<String, Value>, allowed: &[&str]) -> Result<(), String> {
for key in args.keys() {
if key != "_meta" && !allowed.contains(&key.as_str()) {
return Err(format!(
"unknown argument {key}; this tool takes {}",
allowed.join(", ")
));
}
}
Ok(())
}
fn space_from(args: &Map<String, Value>) -> Result<crate::cli::SpaceArg, String> {
match opt_str(args, "space")? {
None | Some("global") => Ok(crate::cli::SpaceArg::Global),
Some("monitor") => Ok(crate::cli::SpaceArg::Monitor),
Some("window") => Ok(crate::cli::SpaceArg::Window),
Some(other) => Err(format!(
"space must be global, monitor, or window; got {other}"
)),
}
}
fn units_from(args: &Map<String, Value>) -> Result<crate::cli::UnitsArg, String> {
match opt_str(args, "units")? {
None | Some("auto") => Ok(crate::cli::UnitsArg::Auto),
Some("physical") => Ok(crate::cli::UnitsArg::Physical),
Some("logical") => Ok(crate::cli::UnitsArg::Logical),
Some(other) => Err(format!(
"units must be auto, physical, or logical; got {other}"
)),
}
}
fn report_result(structured: &Value, summary: &str) -> Value {
json!({
"content": [{ "type": "text", "text": summary }],
"structuredContent": structured,
"isError": false,
})
}
fn call_tool<P: CaptureProvider>(provider: &P, params: Option<&Value>) -> Result<Value, String> {
let params = params.ok_or("tools/call needs params")?;
let name = params
.get("name")
.and_then(Value::as_str)
.ok_or("tools/call needs a tool name")?;
let empty = Map::new();
let args = match params.get("arguments") {
None | Some(Value::Null) => &empty,
Some(Value::Object(map)) => map,
Some(other) => return Err(format!("arguments must be an object, got {other}")),
};
match name {
"pixelcoords_sessions" => tool_sessions(args),
"pixelcoords_resolve" => tool_resolve(provider, args),
"pixelcoords_assert" => tool_assert(args),
"pixelcoords_wait" => tool_wait(provider, args),
"pixelcoords_find" => tool_find(provider, args),
"pixelcoords_diff" => tool_diff(provider, args),
other => Err(format!(
"unknown tool {other}; this server serves {}",
TOOLS.join(", ")
)),
}
}
fn tool_sessions(args: &Map<String, Value>) -> Result<Value, String> {
reject_unknown(args, &["root"])?;
let root = match opt_str(args, "root")? {
Some(path) => std::path::PathBuf::from(path),
None => crate::captures_root(dirs::download_dir()),
};
let entries = crate::sessions_under(&root);
let listed: Vec<Value> = entries
.iter()
.map(|entry| {
json!({
"path": entry.path.to_string_lossy(),
"name": entry.name,
"created_utc": entry.created,
"labels": entry.labels,
"summary": entry.summary,
})
})
.collect();
let summary = if listed.is_empty() {
format!(
"No sessions under {}. A session is a set of screen regions a human marked in \
the pixelcoords overlay — ask the user to run `pixelcoords`, mark what matters, \
and press S.",
root.display()
)
} else {
format!("{} session(s) under {}", listed.len(), root.display())
};
Ok(json!({
"content": [{ "type": "text", "text": summary }],
"structuredContent": { "root": root.to_string_lossy(), "sessions": listed },
"isError": false,
}))
}
fn tool_resolve<P: CaptureProvider>(
provider: &P,
args: &Map<String, Value>,
) -> Result<Value, String> {
reject_unknown(args, &["session", "label", "space", "units", "relocate"])?;
let session = std::path::PathBuf::from(arg_str(args, "session")?);
let label = opt_str(args, "label")?;
let space = space_from(args)?;
let units = units_from(args)?;
let relocate = opt_bool(args, "relocate")?.unwrap_or(false);
let report = crate::run_resolve(provider, &session, label, space, units, relocate)
.map_err(|e| format!("{e:#}"))?;
let summary = format!(
"{} region(s) resolved{}",
report.results.len(),
if relocate { ", relocated" } else { "" }
);
Ok(report_result(
&serde_json::to_value(&report).map_err(|e| format!("serializing the report: {e}"))?,
&summary,
))
}
fn tool_assert(args: &Map<String, Value>) -> Result<Value, String> {
use pixelcoords_core::report::{Command, Report};
reject_unknown(
args,
&["session", "point", "points", "expect", "space", "monitor"],
)?;
let session = std::path::PathBuf::from(arg_str(args, "session")?);
let expect = opt_str(args, "expect")?;
let space = space_from(args)?;
let monitor = opt_u64(args, "monitor")?
.map(usize::try_from)
.transpose()
.map_err(|_| "monitor is out of range".to_string())?;
let verdicts = match (args.get("point"), args.get("points")) {
(Some(_), Some(_)) => {
return Err("give point or points, not both".into());
}
(None, None) => return Err("point or points is required".into()),
(Some(_), None) => {
let point = arg_str(args, "point")?;
vec![
crate::assess_session(&session, point, expect, space, monitor)
.map_err(|e| format!("{e:#}"))?,
]
}
(None, Some(Value::Array(items))) => {
let mut lines = String::new();
for item in items {
let Value::String(text) = item else {
return Err(format!(
"every entry in points must be a string, got {item}"
));
};
lines.push_str(text);
lines.push('\n');
}
crate::assess_stream(&session, lines.as_bytes(), expect, space, monitor)
.map_err(|e| format!("{e:#}"))?
}
(None, Some(other)) => {
return Err(format!("points must be an array of strings, got {other}"));
}
};
let ok = verdicts.iter().all(|v| v.hit);
let hits = verdicts.iter().filter(|v| v.hit).count();
let report = Report::offline(Command::Assert, ok, verdicts);
Ok(report_result(
&serde_json::to_value(&report).map_err(|e| format!("serializing the report: {e}"))?,
&format!("{hits}/{} point(s) hit", report.results.len()),
))
}
fn tool_wait<P: CaptureProvider>(provider: &P, args: &Map<String, Value>) -> Result<Value, String> {
use pixelcoords_core::wait::Condition;
reject_unknown(
args,
&[
"session",
"label",
"condition",
"timeout_secs",
"interval_ms",
"min_score",
],
)?;
let session = std::path::PathBuf::from(arg_str(args, "session")?);
let label = opt_str(args, "label")?;
let condition = match opt_str(args, "condition")? {
None | Some("match") => Condition::Match,
Some("change") => Condition::Change,
Some(other) => return Err(format!("condition must be match or change; got {other}")),
};
let timeout = opt_u64(args, "timeout_secs")?.unwrap_or(30);
if timeout == 0 || timeout > MAX_WAIT_SECS {
return Err(format!(
"timeout_secs must be 1 to {MAX_WAIT_SECS}; got {timeout}. The server answers \
nothing else while a wait runs, so the ceiling is deliberate."
));
}
let interval = opt_u64(args, "interval_ms")?.unwrap_or(500);
if interval == 0 {
return Err("interval_ms must be at least 1".into());
}
let min_score = opt_f64(args, "min_score")?.unwrap_or(pixelcoords_core::locate::SCORE_FLOOR);
let (budget, interval) =
crate::wait_setup(&format!("{timeout}s"), &format!("{interval}ms"), min_score)
.map_err(|e| format!("{e:#}"))?;
let report = crate::run_wait(
provider, &session, label, condition, budget, interval, min_score,
)
.map_err(|e| format!("{e:#}"))?;
let summary = if report.ok {
format!("condition met after {} poll(s)", report.polls.unwrap_or(0))
} else {
format!(
"timed out after {} poll(s) — the condition never held",
report.polls.unwrap_or(0)
)
};
Ok(report_result(
&serde_json::to_value(&report).map_err(|e| format!("serializing the report: {e}"))?,
&summary,
))
}
fn tool_find<P: CaptureProvider>(provider: &P, args: &Map<String, Value>) -> Result<Value, String> {
reject_unknown(args, &["session", "label"])?;
let session = std::path::PathBuf::from(arg_str(args, "session")?);
let label = opt_str(args, "label")?;
let report = crate::run_find(provider, &session, label).map_err(|e| format!("{e:#}"))?;
let found = report.results.iter().filter(|r| r.found).count();
Ok(report_result(
&serde_json::to_value(&report).map_err(|e| format!("serializing the report: {e}"))?,
&format!("{found}/{} region(s) located", report.results.len()),
))
}
fn tool_diff<P: CaptureProvider>(provider: &P, args: &Map<String, Value>) -> Result<Value, String> {
reject_unknown(args, &["session", "label", "against", "tolerance"])?;
let session = std::path::PathBuf::from(arg_str(args, "session")?);
let label = opt_str(args, "label")?;
let against = opt_str(args, "against")?.map(std::path::PathBuf::from);
let tolerance = opt_f64(args, "tolerance")?.unwrap_or(0.0);
let report = crate::run_diff(provider, &session, against.as_deref(), label, tolerance)
.map_err(|e| format!("{e:#}"))?;
let over = report
.results
.iter()
.filter(|r| r.diff.changed_pct > tolerance)
.count();
let summary = if report.ok {
format!("{} region(s) within tolerance", report.results.len())
} else {
format!(
"{over}/{} region(s) changed beyond tolerance",
report.results.len()
)
};
Ok(report_result(
&serde_json::to_value(&report).map_err(|e| format!("serializing the report: {e}"))?,
&summary,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capture::FakeCapture;
use serde_json::json;
fn ask(request: &Value) -> Value {
let line = handle_line(&FakeCapture, &request.to_string()).expect("a reply");
serde_json::from_str(&line).expect("valid JSON out")
}
fn result(request: &Value) -> Value {
let mut reply = ask(request);
reply["result"].take()
}
fn error_code(request: &Value) -> i64 {
ask(request)["error"]["code"]
.as_i64()
.expect("an error code")
}
fn fixture(name: &str) -> std::path::PathBuf {
use pixelcoords_core::geometry::{Point, Rect, Shape, Size};
use pixelcoords_core::selection::Selection;
use pixelcoords_core::session::{MonitorRecord, SessionFile};
let mut email = Selection::new(Shape::Rect(Rect::new(10, 10, 40, 20)), 0);
email.label = "email".into();
let mut submit = Selection::new(Shape::Rect(Rect::new(10, 60, 40, 20)), 0);
submit.label = "submit".into();
let file = SessionFile::build(
"test",
"2026-08-03T00:00:00Z".into(),
vec![MonitorRecord {
index: 0,
name: "Fake".into(),
primary: true,
origin_px: Point::new(0, 0),
size_px: Size::new(160, 120),
scale: 2.0,
}],
&[email, submit],
&["crop-0-email.png".into(), "crop-1-submit.png".into()],
None,
);
let dir = std::env::temp_dir().join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("session.json"),
serde_json::to_string(&file).unwrap(),
)
.unwrap();
dir
}
fn call(tool: &str, arguments: &Value) -> Value {
result(&json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": tool, "arguments": arguments },
}))
}
#[test]
fn discover_advertises_versions_capabilities_and_identity() {
let r = result(&json!({"jsonrpc": "2.0", "id": 1, "method": "server/discover"}));
assert_eq!(r["protocolVersions"][0], "2026-07-28");
assert!(r["capabilities"]["tools"].is_object());
assert_eq!(r["serverInfo"]["name"], "pixelcoords");
assert_eq!(r["serverInfo"]["version"], env!("CARGO_PKG_VERSION"));
}
#[test]
fn every_result_carries_result_type_and_server_info() {
for method in ["server/discover", "initialize", "tools/list"] {
let r = result(&json!({"jsonrpc": "2.0", "id": 1, "method": method}));
assert_eq!(r["resultType"], "complete", "{method}");
assert_eq!(
r["_meta"][META_SERVER_INFO]["name"], "pixelcoords",
"{method}"
);
}
}
#[test]
fn older_clients_can_still_open_with_initialize() {
let r = result(&json!({"jsonrpc": "2.0", "id": 1, "method": "initialize"}));
assert_eq!(r["protocolVersion"], "2026-07-28");
assert!(r["capabilities"]["tools"].is_object());
}
#[test]
fn tools_list_is_deterministic_and_cacheable() {
let first = result(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}));
let again = result(&json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}));
let names: Vec<&str> = first["tools"]
.as_array()
.unwrap()
.iter()
.map(|t| t["name"].as_str().unwrap())
.collect();
assert_eq!(names, TOOLS, "order is what lets a client cache");
assert_eq!(first["tools"], again["tools"]);
assert!(first["ttlMs"].as_u64().is_some_and(|ms| ms > 0));
assert_eq!(first["cacheScope"], "public");
}
#[test]
fn every_tool_declares_a_schema_and_says_whether_it_captures() {
let r = result(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}));
for tool in r["tools"].as_array().unwrap() {
let name = tool["name"].as_str().unwrap();
assert!(tool["description"].is_string(), "{name} has no description");
assert!(
tool["inputSchema"]["type"] == "object",
"{name} has no object schema"
);
assert_eq!(
tool["inputSchema"]["additionalProperties"], false,
"{name} accepts unknown arguments"
);
let described = tool["description"].as_str().unwrap().to_lowercase();
assert!(
described.contains("captur"),
"{name} does not say whether it captures: {described}"
);
}
}
#[test]
fn an_unsupported_protocol_version_is_refused_with_the_reserved_code() {
assert_eq!(
error_code(&json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
"params": { "_meta": { META_PROTOCOL_VERSION: "1999-01-01" } },
})),
ERR_UNSUPPORTED_PROTOCOL
);
}
#[test]
fn a_supported_version_in_meta_passes_through() {
for version in PROTOCOL_VERSIONS {
let reply = ask(&json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
"params": { "_meta": { META_PROTOCOL_VERSION: version } },
}));
assert!(reply["error"].is_null(), "{version} was refused");
}
}
#[test]
fn unknown_meta_keys_are_tolerated() {
let reply = ask(&json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
"params": { "_meta": {
META_PROTOCOL_VERSION: "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "someone" },
"io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/logLevel": "debug",
"traceparent": "00-abc-def-01",
"something.invented.later": true,
} },
}));
assert!(reply["error"].is_null(), "a future `_meta` key was refused");
}
#[test]
fn malformed_requests_are_refused_by_kind() {
assert_eq!(
serde_json::from_str::<Value>(
&handle_line(&FakeCapture, "{not json").expect("a reply")
)
.unwrap()["error"]["code"],
ERR_PARSE
);
assert_eq!(
error_code(&json!({"id": 1, "method": "tools/list"})),
ERR_INVALID_REQUEST,
"missing jsonrpc"
);
assert_eq!(
error_code(&json!({"jsonrpc": "2.0", "id": 1})),
ERR_INVALID_REQUEST,
"missing method"
);
assert_eq!(
error_code(&json!({"jsonrpc": "2.0", "id": 1, "method": "sorcery"})),
ERR_METHOD_NOT_FOUND
);
assert_eq!(
error_code(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call"})),
ERR_INVALID_PARAMS,
"no params"
);
}
#[test]
fn a_notification_gets_no_reply() {
assert!(handle_line(&FakeCapture, r#"{"jsonrpc":"2.0","method":"tools/list"}"#).is_none());
}
#[test]
fn a_miss_is_an_answer_not_an_error() {
let dir = fixture("mcp-miss");
let hit = call(
"pixelcoords_assert",
&json!({ "session": dir.to_string_lossy(), "point": "30,20" }),
);
assert_eq!(hit["isError"], false);
assert_eq!(hit["structuredContent"]["ok"], true);
let miss = call(
"pixelcoords_assert",
&json!({ "session": dir.to_string_lossy(), "point": "150,110" }),
);
assert_eq!(
miss["isError"], false,
"a miss must not look like a broken tool"
);
assert_eq!(miss["structuredContent"]["ok"], false);
assert_eq!(miss["structuredContent"]["results"][0]["hit"], false);
}
#[test]
fn a_malformed_question_is_an_error() {
assert_eq!(
error_code(&json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "pixelcoords_assert", "arguments": {
"session": "/nowhere/at/all", "point": "1,1" } },
})),
ERR_INVALID_PARAMS
);
}
#[test]
fn unknown_tools_and_arguments_name_what_was_expected() {
let reply = ask(&json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "pixelcoords_teleport" },
}));
let message = reply["error"]["message"].as_str().unwrap();
assert!(message.contains("pixelcoords_teleport"), "{message}");
assert!(message.contains("pixelcoords_resolve"), "{message}");
let reply = ask(&json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "pixelcoords_resolve", "arguments": {
"session": "x", "untis": "auto" } },
}));
let message = reply["error"]["message"].as_str().unwrap();
assert!(
message.contains("untis"),
"the typo is not named: {message}"
);
assert!(
message.contains("units"),
"the real name is not offered: {message}"
);
}
#[test]
fn a_missing_required_argument_says_which() {
let reply = ask(&json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "pixelcoords_resolve", "arguments": {} },
}));
assert!(
reply["error"]["message"]
.as_str()
.unwrap()
.contains("session"),
"{reply}"
);
}
#[test]
fn enum_arguments_are_checked_against_their_values() {
for (key, bad) in [("space", "sideways"), ("units", "furlongs")] {
let reply = ask(&json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "pixelcoords_resolve", "arguments": {
"session": "x", key: bad } },
}));
let message = reply["error"]["message"].as_str().unwrap();
assert!(message.contains(bad), "{key}: {message}");
}
}
#[test]
fn wait_refuses_a_timeout_that_would_hang_the_server() {
let reply = ask(&json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "pixelcoords_wait", "arguments": {
"session": "x", "timeout_secs": MAX_WAIT_SECS + 1 } },
}));
let message = reply["error"]["message"].as_str().unwrap();
assert!(message.contains(&MAX_WAIT_SECS.to_string()), "{message}");
}
#[test]
fn sessions_lists_labels_as_data_not_prose() {
let dir = fixture("mcp-sessions/one");
let root = dir.parent().unwrap();
let r = call(
"pixelcoords_sessions",
&json!({ "root": root.to_string_lossy() }),
);
assert_eq!(r["isError"], false);
let sessions = r["structuredContent"]["sessions"].as_array().unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0]["labels"], json!(["email", "submit"]));
}
#[test]
fn an_empty_root_says_how_to_make_a_session() {
let root = std::env::temp_dir().join("mcp-empty-root");
std::fs::create_dir_all(&root).unwrap();
let r = call(
"pixelcoords_sessions",
&json!({ "root": root.to_string_lossy() }),
);
let text = r["content"][0]["text"].as_str().unwrap();
assert!(text.contains("pixelcoords"), "{text}");
assert!(text.to_lowercase().contains("mark"), "{text}");
}
#[test]
fn resolve_answers_in_the_units_it_was_asked_for() {
let dir = fixture("mcp-resolve");
let session = dir.to_string_lossy().into_owned();
let physical = call(
"pixelcoords_resolve",
&json!({ "session": session, "units": "physical", "label": "email" }),
);
let logical = call(
"pixelcoords_resolve",
&json!({ "session": session, "units": "logical", "label": "email" }),
);
let p = &physical["structuredContent"]["results"][0]["point"];
let l = &logical["structuredContent"]["results"][0]["point"];
assert_eq!(
p["x"].as_i64().unwrap(),
l["x"].as_i64().unwrap() * 2,
"scale 2.0"
);
assert_eq!(
physical["structuredContent"]["results"][0]["label"],
"email"
);
}
#[test]
fn assert_scores_a_whole_trajectory_in_one_call() {
let dir = fixture("mcp-trajectory");
let r = call(
"pixelcoords_assert",
&json!({
"session": dir.to_string_lossy(),
"points": ["30,20", "30,70", "150,110"],
}),
);
assert_eq!(r["isError"], false);
let rows = r["structuredContent"]["results"].as_array().unwrap();
assert_eq!(rows.len(), 3);
assert_eq!(rows[0]["hit"], true);
assert_eq!(rows[1]["hit"], true);
assert_eq!(rows[2]["hit"], false);
assert_eq!(
r["structuredContent"]["ok"], false,
"one miss fails the set"
);
}
#[test]
fn assert_refuses_both_point_and_points() {
let dir = fixture("mcp-both");
let reply = ask(&json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "pixelcoords_assert", "arguments": {
"session": dir.to_string_lossy(), "point": "1,1", "points": ["1,1"] } },
}));
assert!(
reply["error"]["message"]
.as_str()
.unwrap()
.contains("not both")
);
}
}