use crate::filter::Filter;
use crate::fleet::Fleet;
use anyhow::Result;
use serde_json::{Value, json};
use std::io::{BufRead, Write};
use std::path::PathBuf;
use std::time::Duration;
pub const SUPPORTED: [&str; 4] = ["2026-07-28", "2025-11-25", "2025-06-18", "2024-11-05"];
const MODERN: &str = "2026-07-28";
const LEGACY_NEWEST: &str = "2025-11-25";
const LEGACY_OLDEST: &str = "2024-11-05";
const META_VERSION: &str = "io.modelcontextprotocol/protocolVersion";
const META_SERVER_INFO: &str = "io.modelcontextprotocol/serverInfo";
const UNSUPPORTED_VERSION: i64 = -32022;
const TTL_MS: u64 = 3_600_000;
const FILE_LIMIT: usize = 200;
const INDEX_BUDGET: Duration = Duration::from_secs(45);
type Fail = (i64, String, Option<Value>);
pub trait Writer: Send + Sync {
fn index(&self, store: Option<&str>, paths: &[PathBuf]) -> Result<String, String>;
fn forget(&self, store: Option<&str>, path: &str) -> Result<String, String>;
fn add(&self, store: Option<&str>, url: &str) -> Result<String, String>;
}
pub fn answer(stores: &mut Fleet, writer: Option<&dyn Writer>, request: &Value) -> Option<Value> {
let id = request.get("id").cloned()?;
let method = request.get("method").and_then(Value::as_str).unwrap_or("");
let params = request.get("params").cloned().unwrap_or(json!({}));
Some(reply(&id, dispatch(stores, writer, method, ¶ms)))
}
pub fn serve(stores: &mut Fleet, input: impl BufRead, mut output: impl Write) -> Result<()> {
stores.quiet = true;
for line in input.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let req: Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(e) => {
respond(
&mut output,
&json!(null),
Err((-32700, e.to_string(), None)),
)?;
continue;
}
};
let Some(id) = req.get("id").cloned() else {
continue;
};
let method = req.get("method").and_then(Value::as_str).unwrap_or("");
let params = req.get("params").cloned().unwrap_or(json!({}));
let result = dispatch(stores, None, method, ¶ms);
respond(&mut output, &id, result)?;
}
Ok(())
}
fn declared_version(params: &Value) -> Option<&str> {
params.get("_meta")?.get(META_VERSION)?.as_str()
}
fn server_info() -> Value {
json!({ "name": "semlith", "version": env!("CARGO_PKG_VERSION") })
}
fn modernize(mut result: Value, cacheable: Option<&str>) -> Value {
result["resultType"] = json!("complete");
result["_meta"] = json!({ META_SERVER_INFO: server_info() });
if let Some(scope) = cacheable {
result["ttlMs"] = json!(TTL_MS);
result["cacheScope"] = json!(scope);
}
result
}
fn dispatch(
stores: &mut Fleet,
writer: Option<&dyn Writer>,
method: &str,
params: &Value,
) -> Result<Value, Fail> {
let declared = declared_version(params);
if let Some(version) = declared
&& !SUPPORTED.contains(&version)
{
return Err((
UNSUPPORTED_VERSION,
"Unsupported protocol version".into(),
Some(json!({ "supported": SUPPORTED, "requested": version })),
));
}
let modern = declared == Some(MODERN);
match method {
"server/discover" => Ok(modernize(
json!({
"supportedVersions": SUPPORTED,
"capabilities": { "tools": {} },
"instructions":
"Search and maintain the local semlith stores this server was opened on. \
Call semlith_stats first to learn the store names the other tools accept.",
}),
Some("public"),
)),
"initialize" => {
let asked = params.get("protocolVersion").and_then(Value::as_str);
let version = negotiate(asked);
match asked {
Some(a) if a != version => {
eprintln!("semlith: client asked for MCP {a}; answering {version}")
}
_ => eprintln!("semlith: MCP {version}"),
}
Ok(json!({
"protocolVersion": version,
"capabilities": { "tools": {} },
"serverInfo": server_info(),
}))
}
"ping" => Ok(json!({})),
"tools/list" => {
let listed = json!({ "tools": tools(stores) });
Ok(if modern {
modernize(listed, Some("private"))
} else {
listed
})
}
"tools/call" => {
let called = call_tool(stores, writer, params)?;
Ok(if modern {
modernize(called, None)
} else {
called
})
}
other => Err((-32601, format!("unknown method: {other}"), None)),
}
}
fn negotiate(requested: Option<&str>) -> &'static str {
match requested {
Some(v) => SUPPORTED
.iter()
.find(|s| **s == v && **s != MODERN)
.copied()
.unwrap_or(LEGACY_NEWEST),
None => LEGACY_OLDEST,
}
}
fn tools(stores: &Fleet) -> Value {
tool_defs(&stores.labels().join(", "))
}
pub fn tool_names() -> Vec<String> {
tool_list().into_iter().map(|(name, _)| name).collect()
}
pub fn tool_list() -> Vec<(String, String)> {
tool_defs("")
.as_array()
.map(|defs| {
defs.iter()
.filter_map(|tool| {
let name = tool["name"].as_str()?.to_string();
let about = tool["annotations"]["title"]
.as_str()
.or_else(|| tool["description"].as_str())
.unwrap_or_default()
.to_string();
Some((name, about))
})
.collect()
})
.unwrap_or_default()
}
fn tool_defs(open: &str) -> Value {
let store_arg = format!(
"Restrict the search to these stores by name. Open stores: {open}. \
Omit to search all of them, which is usually right — narrow only when \
you already know which corpus holds the answer."
);
let write_store_arg = format!(
"Which store to write to, by name. Open stores: {open}. \
Required when more than one is open, since a store takes one writer."
);
json!([
{
"name": "semlith_search",
"description":
"Semantic search over the local semlith store: docs, PDFs, code, and notes \
that have been indexed on this machine. Returns the most relevant excerpts \
with their file path and line range. Use this instead of reading whole files \
when you need to find where something is discussed or implemented. \
Optionally narrow the search to one part of the corpus with path, ext or lang.",
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "What you are looking for. A question in plain English, or an exact identifier — both are searched."
},
"k": {
"type": "integer",
"description": "How many excerpts to return (default 8).",
"minimum": 1,
"maximum": 50
},
"path": {
"type": "array",
"items": { "type": "string" },
"description":
"Restrict the search to files matching these globs, e.g. \"src/**\". \
A pattern that does not start with / matches anywhere in the tree. \
`*` crosses directory separators, so `src/*` reaches the whole \
subtree. Only filter when you already know which part of the \
corpus holds the answer — a wrong guess hides it entirely."
},
"ext": {
"type": "array",
"items": { "type": "string" },
"description": "Restrict the search to these file extensions, e.g. [\"rs\", \"toml\"]."
},
"lang": {
"type": "array",
"items": { "type": "string" },
"description":
"Restrict the search to these languages, e.g. [\"rust\"]. Accepts: \
c, cpp, csharp, css, go, haskell, html, java, javascript, json, \
kotlin, lua, markdown, ocaml, php, python, ruby, rust, scala, \
shell, sql, swift, toml, typescript, yaml."
},
"store": {
"type": "array",
"items": { "type": "string" },
"description": store_arg
}
},
"required": ["query"]
},
"annotations": { "title": "Search the semlith store", "readOnlyHint": true }
},
{
"name": "semlith_stats",
"description":
"Report what the local semlith stores currently contain: file count, chunk \
count, indexed bytes, and the embedding model, one line per store. Use this \
to check whether a corpus is indexed before searching it, and to learn the \
store names the other tools accept.",
"inputSchema": { "type": "object", "properties": {} },
"annotations": { "title": "Describe the semlith stores", "readOnlyHint": true }
},
{
"name": "semlith_files",
"description":
"List the files currently indexed, narrowed the same way as a search. Use it \
to tell \"this file is not indexed\" apart from \"the corpus does not discuss \
this\", which a search returning nothing cannot say.",
"inputSchema": {
"type": "object",
"properties": {
"path": {
"type": "array",
"items": { "type": "string" },
"description": "Only list files matching these globs, e.g. \"src/**\"."
},
"ext": {
"type": "array",
"items": { "type": "string" },
"description": "Only list files with these extensions, e.g. [\"rs\"]."
},
"lang": {
"type": "array",
"items": { "type": "string" },
"description": "Only list files of these languages, e.g. [\"rust\"]."
},
"store": {
"type": "array",
"items": { "type": "string" },
"description": store_arg
},
"limit": {
"type": "integer",
"description": "How many paths to return before counting the rest (default 200).",
"minimum": 1
}
}
},
"annotations": { "title": "List indexed files", "readOnlyHint": true }
},
{
"name": "semlith_index",
"description":
"Index files and directories into a store that is already open, so their \
contents become searchable in this session. Only what has changed is \
re-embedded. Large corpora take longer than one tool call allows: the call \
returns what it managed and how much is left, and calling it again continues \
from there.",
"inputSchema": {
"type": "object",
"properties": {
"path": {
"type": "array",
"items": { "type": "string" },
"description": "Files or directories to index. Directories are walked, honouring .gitignore."
},
"store": { "type": "string", "description": write_store_arg }
},
"required": ["path"]
},
"annotations": { "title": "Index files into the store", "readOnlyHint": false }
},
{
"name": "semlith_add",
"description":
"Fetch one URL into the store and index it, so its contents become \
searchable in this session: a web page, a PDF such as an arXiv paper, or a \
file on GitHub (a /blob/ link is rewritten to the raw file). Exactly one \
request is made, for exactly this URL — nothing is crawled, no link is \
followed, and no credential is ever sent. The file is written inside the \
store's own downloads directory, never into the user's working tree. \
Refused when semlith is running with --airgap.",
"inputSchema": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The https URL to fetch. Plain http is refused."
},
"store": { "type": "string", "description": write_store_arg }
},
"required": ["url"]
},
"annotations": {
"title": "Add a URL to the store",
"readOnlyHint": false,
"openWorldHint": true
}
},
{
"name": "semlith_forget",
"description":
"Remove one file from a store, so it stops appearing in searches. The file on \
disk is untouched; only what was indexed about it goes.",
"inputSchema": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "The file to remove from the index." },
"store": { "type": "string", "description": write_store_arg }
},
"required": ["path"]
},
"annotations": {
"title": "Forget a file",
"readOnlyHint": false,
"destructiveHint": true
}
},
{
"name": "semlith_symbol",
"description":
"Find where a symbol is defined, by exact name, across every indexed store. \
Returns the file and line range of each definition. Use this instead of \
grepping for `fn name` or `def name`: it reads the definition out of the \
parsed syntax tree, so it does not match the name in a comment, a string, \
or a call. Six languages carry symbols — Rust, TypeScript, Python, Go, Java \
and C.",
"inputSchema": {
"type": "object",
"properties": {
"name": { "type": "string", "description": "The symbol's name, matched exactly." },
"k": { "type": "integer", "description": "Most definitions to return. Default 20." },
"store": { "type": "string", "description": store_arg }
},
"required": ["name"]
},
"annotations": { "title": "Find a symbol's definition", "readOnlyHint": true }
},
{
"name": "semlith_neighbors",
"description":
"List what calls a symbol and what it calls, one hop in each direction, from \
the extracted code graph. Use this to answer \"who uses this\" and \"what \
does this depend on\" in one call rather than a chain of greps. Each edge \
says whether it was resolved through an import (extracted) or matched by \
name (inferred) — do not treat an inferred edge as certain.",
"inputSchema": {
"type": "object",
"properties": {
"name": { "type": "string", "description": "The symbol's name, matched exactly." },
"kind": {
"type": "array",
"items": { "type": "string" },
"description":
"Only follow edges of these kinds: defines, calls, imports, \
references, contains. Omit for all of them."
},
"store": { "type": "string", "description": store_arg }
},
"required": ["name"]
},
"annotations": { "title": "List a symbol's callers and callees", "readOnlyHint": true }
},
{
"name": "semlith_path",
"description":
"Show the shortest chain of edges from one symbol to another, naming every \
edge in it. Use it to find out how two parts of a codebase are connected, \
or to confirm that they are not. Follows calls, imports and references; \
returns nothing when the two are unconnected within the depth searched, \
which is an answer rather than a failure.",
"inputSchema": {
"type": "object",
"properties": {
"from": { "type": "string", "description": "The symbol the chain starts at." },
"to": { "type": "string", "description": "The symbol the chain ends at." },
"depth": { "type": "integer", "description": "Most hops to search. Default 6." },
"store": { "type": "string", "description": store_arg }
},
"required": ["from", "to"]
},
"annotations": { "title": "Find the path between two symbols", "readOnlyHint": true }
}
])
}
fn call_tool(
stores: &mut Fleet,
writer: Option<&dyn Writer>,
params: &Value,
) -> Result<Value, Fail> {
let name = params.get("name").and_then(Value::as_str).unwrap_or("");
let args = params.get("arguments").cloned().unwrap_or(json!({}));
let body = match name {
"semlith_search" => {
let Some(query) = args.get("query").and_then(Value::as_str) else {
return Err((-32602, "missing required argument: query".into(), None));
};
let k = args.get("k").and_then(Value::as_u64).unwrap_or(8) as usize;
let filter = match filter_of(&args) {
Ok(f) => f,
Err(e) => return Ok(tool_error(&e)),
};
let only = strings(&args, "store");
let selected = if filter.is_empty() {
1
} else {
match stores.matching_files(&filter) {
Ok(n) => n,
Err(e) => return Ok(tool_error(&format!("search failed: {e}"))),
}
};
if selected == 0 {
"No indexed file matches that path/ext/lang filter. Try again without it."
.to_string()
} else {
match stores.search_in(Some(&only), query, k.clamp(1, 50), &filter) {
Ok(hits) if hits.is_empty() => "No matches in the semlith store.".to_string(),
Ok(hits) => render(&hits),
Err(e) => return Ok(tool_error(&e.to_string())),
}
}
}
"semlith_stats" => {
let many = stores.len() > 1;
let mut lines = Vec::new();
for (label, store) in stores.each() {
let (files, chunks, bytes) = match store.stats() {
Ok(s) => s,
Err(e) => return Ok(tool_error(&format!("stats failed: {e}"))),
};
let body = format!(
"{files} files, {chunks} chunks, {} indexed, model {} ({} dim)",
crate::human_bytes(bytes),
store.model(),
store.dim(),
);
lines.push(if many {
format!("{label}: {body}")
} else {
body
});
}
lines.join("\n")
}
"semlith_files" => {
let filter = match filter_of(&args) {
Ok(f) => f,
Err(e) => return Ok(tool_error(&e)),
};
let only = strings(&args, "store");
let limit = args
.get("limit")
.and_then(Value::as_u64)
.map(|n| n as usize)
.unwrap_or(FILE_LIMIT)
.max(1);
match stores.paths_in(Some(&only), &filter, limit) {
Ok((paths, _)) if paths.is_empty() => {
"No file in the semlith store matches that.".to_string()
}
Ok((paths, left_out)) => {
let mut out = paths.join("\n");
if left_out > 0 {
out.push_str(&format!(
"\n… and {left_out} more. Narrow with path/ext/lang, or raise limit."
));
}
out
}
Err(e) => return Ok(tool_error(&e.to_string())),
}
}
"semlith_index" => {
let roots: Vec<PathBuf> = strings(&args, "path").iter().map(PathBuf::from).collect();
if roots.is_empty() {
return Err((-32602, "missing required argument: path".into(), None));
}
if let Some(writer) = writer {
let named = strings(&args, "store");
return Ok(
match writer.index(named.first().map(String::as_str), &roots) {
Ok(text) => json!({ "content": [{ "type": "text", "text": text }] }),
Err(e) => tool_error(&e),
},
);
}
let store = match stores.writable(&strings(&args, "store")) {
Ok(s) => s,
Err(e) => return Ok(tool_error(&e.to_string())),
};
store.boundary = crate::Boundary::within(crate::home::index_roots(store.dir()));
match store.index_paths_within(&roots, index_budget(), |_, _| {}) {
Err(e) => return Ok(tool_error(&e.to_string())),
Ok(report) => {
let mut out = format!(
"{} indexed, {} unchanged, {} skipped, {} removed ({} chunks)",
report.indexed,
report.unchanged,
report.skipped,
report.removed,
report.chunks,
);
if report.remaining > 0 {
out.push_str(&format!(
"\nStopped at the time limit with {} paths remaining. \
Call semlith_index again with the same arguments to continue; \
nothing already indexed is redone.",
report.remaining
));
}
for (path, why) in &report.refused {
out.push_str(&format!("\nrefused: {path} — {why}"));
}
out
}
}
}
"semlith_add" => {
let urls = strings(&args, "url");
let url = match urls.as_slice() {
[one] => one.clone(),
[] => return Err((-32602, "missing required argument: url".into(), None)),
many => {
return Ok(tool_error(&format!(
"semlith_add fetches one URL, not {}: {}",
many.len(),
many.join(", ")
)));
}
};
let named = strings(&args, "store");
if let Some(writer) = writer {
return Ok(match writer.add(named.first().map(String::as_str), &url) {
Ok(text) => json!({ "content": [{ "type": "text", "text": text }] }),
Err(e) => tool_error(&e),
});
}
let store = match stores.writable(&named) {
Ok(s) => s,
Err(e) => return Ok(tool_error(&e.to_string())),
};
let fetched = match crate::add::fetch(&url, store.dir()) {
Ok(fetched) => fetched,
Err(e) => return Ok(tool_error(&format!("{e:#}"))),
};
match store.index_paths_within(
std::slice::from_ref(&fetched.path),
index_budget(),
|_, _| {},
) {
Err(e) => return Ok(tool_error(&e.to_string())),
Ok(report) => format!(
"Fetched {} into {} and indexed {} chunks.",
fetched.url,
fetched.path.display(),
report.chunks
),
}
}
"semlith_forget" => {
let paths = strings(&args, "path");
let path = match paths.as_slice() {
[one] => one.clone(),
[] => return Err((-32602, "missing required argument: path".into(), None)),
many => {
return Ok(tool_error(&format!(
"semlith_forget removes one file, not {}: {}",
many.len(),
many.join(", ")
)));
}
};
if let Some(writer) = writer {
let named = strings(&args, "store");
return Ok(
match writer.forget(named.first().map(String::as_str), &path) {
Ok(text) => json!({ "content": [{ "type": "text", "text": text }] }),
Err(e) => tool_error(&e),
},
);
}
let store = match stores.writable(&strings(&args, "store")) {
Ok(s) => s,
Err(e) => return Ok(tool_error(&e.to_string())),
};
match store.forget(std::path::Path::new(&path)) {
Ok((0, 0)) => format!("{path} was not indexed; nothing removed."),
Ok((0, images)) => format!("Removed {images} image vector(s) for {path}."),
Ok((chunks, 0)) => format!("Removed {chunks} chunks for {path}."),
Ok((chunks, images)) => {
format!("Removed {chunks} chunks and {images} image vector(s) for {path}.")
}
Err(e) => return Ok(tool_error(&e.to_string())),
}
}
"semlith_symbol" => {
let Some(name) = args.get("name").and_then(Value::as_str) else {
return Err((-32602, "missing required argument: name".into(), None));
};
let k = args.get("k").and_then(Value::as_u64).unwrap_or(20) as usize;
let only = strings(&args, "store");
match stores.symbols_in(Some(&only), name, k.clamp(1, 200)) {
Ok(found) if found.is_empty() => empty_graph(stores, name),
Ok(found) => found
.iter()
.map(|s| {
format!(
"{} ({}) {}{}:{}-{}",
s.name,
s.kind,
label_of(&s.store),
s.path,
s.start_line,
s.end_line
)
})
.collect::<Vec<_>>()
.join("\n"),
Err(e) => return Ok(tool_error(&e.to_string())),
}
}
"semlith_neighbors" => {
let Some(name) = args.get("name").and_then(Value::as_str) else {
return Err((-32602, "missing required argument: name".into(), None));
};
let kinds = strings(&args, "kind");
if let Some(bad) = kinds
.iter()
.find(|k| !crate::graph::KINDS.contains(&k.as_str()))
{
return Ok(tool_error(&format!(
"unknown edge kind {bad:?}; the kinds are {}",
crate::graph::KINDS.join(", ")
)));
}
let only = strings(&args, "store");
match stores.neighbours_in(Some(&only), name, &kinds) {
Ok(n) if n.callers.is_empty() && n.callees.is_empty() => empty_graph(stores, name),
Ok(n) => format!(
"callers of {name} ({}):\n{}\n\ncallees of {name} ({}):\n{}",
n.callers.len(),
render_ends(&n.callers),
n.callees.len(),
render_ends(&n.callees),
),
Err(e) => return Ok(tool_error(&e.to_string())),
}
}
"semlith_path" => {
let (Some(from), Some(to)) = (
args.get("from").and_then(Value::as_str),
args.get("to").and_then(Value::as_str),
) else {
return Err((-32602, "missing required arguments: from, to".into(), None));
};
let depth = args.get("depth").and_then(Value::as_u64).unwrap_or(6) as u32;
let only = strings(&args, "store");
match stores.path_in(Some(&only), from, to, depth.clamp(1, 20)) {
Ok(Some(steps)) if steps.is_empty() => format!("{from} is {to}."),
Ok(Some(steps)) => {
let chain = steps
.iter()
.map(|s| format!("{} --{}({})--> {}", s.from, s.kind, s.confidence, s.to))
.collect::<Vec<_>>()
.join("\n");
format!("{} hops:\n{chain}", steps.len())
}
Ok(None) => format!(
"No chain from {from} to {to} within {depth} hops. They may be \
unconnected, or connected only further than that."
),
Err(e) => return Ok(tool_error(&e.to_string())),
}
}
other => return Err((-32602, format!("unknown tool: {other}"), None)),
};
Ok(json!({ "content": [{ "type": "text", "text": body }] }))
}
fn empty_graph(stores: &Fleet, name: &str) -> String {
let symbols: i64 = stores
.each()
.map(|(_, store)| {
crate::store::graph_stats(store.db())
.map(|(s, _)| s)
.unwrap_or(0)
})
.sum();
if symbols == 0 {
"This store has no symbols yet: its graph is built as files are indexed. Call \
semlith_index on the corpus, then ask again."
.to_string()
} else {
format!(
"No symbol named {name} in the graph. Only Rust, TypeScript, Python, Go, Java \
and C carry symbols; semlith_search still finds text in everything else."
)
}
}
fn label_of(store: &Option<String>) -> String {
match store {
Some(label) => format!("[{label}] "),
None => String::new(),
}
}
fn render_ends(ends: &[crate::store::EdgeEnd]) -> String {
if ends.is_empty() {
return " none".to_string();
}
ends.iter()
.map(|e| {
format!(
" {} via {} ({}) {}{}:{}",
e.symbol.name,
e.kind,
e.confidence,
label_of(&e.symbol.store),
e.symbol.path,
e.symbol.start_line
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn index_budget() -> Duration {
match std::env::var("SEMLITH_MCP_INDEX_BUDGET") {
Ok(v) => match v.trim().parse::<u64>() {
Ok(seconds) => Duration::from_secs(seconds),
Err(_) => INDEX_BUDGET,
},
Err(_) => INDEX_BUDGET,
}
}
fn filter_of(args: &Value) -> Result<Filter, String> {
Filter::new(
&strings(args, "path"),
&strings(args, "ext"),
&strings(args, "lang"),
)
.map_err(|e| e.to_string())
}
fn strings(args: &Value, key: &str) -> Vec<String> {
match args.get(key) {
Some(Value::String(s)) => vec![s.clone()],
Some(Value::Array(items)) => items
.iter()
.filter_map(|i| i.as_str().map(str::to_string))
.collect(),
_ => Vec::new(),
}
}
fn tool_error(message: &str) -> Value {
json!({ "content": [{ "type": "text", "text": message }], "isError": true })
}
fn render(hits: &[crate::Hit]) -> String {
let mut out = String::new();
for (i, h) in hits.iter().enumerate() {
let from = match &h.store {
Some(label) => format!("{label} "),
None => String::new(),
};
let via = if h.lists.is_empty() {
String::new()
} else {
format!(" via {}", h.lists.join("+"))
};
if let Some(px) = h.image {
out.push_str(&format!(
"[{}] {from}{} — image, {}x{} px (score {:.3}{via})\n\n",
i + 1,
h.path,
px.width,
px.height,
h.score,
));
continue;
}
out.push_str(&format!(
"[{}] {from}{}:{}-{} (score {:.3}{via})\n{}\n\n",
i + 1,
h.path,
h.start_line,
h.end_line,
h.score,
h.text.trim_end()
));
}
out.trim_end().to_string()
}
fn respond(out: &mut impl Write, id: &Value, result: Result<Value, Fail>) -> std::io::Result<()> {
writeln!(out, "{}", reply(id, result))?;
out.flush()
}
fn reply(id: &Value, result: Result<Value, Fail>) -> Value {
match result {
Ok(value) => json!({ "jsonrpc": "2.0", "id": id, "result": value }),
Err((code, message, data)) => {
let mut error = json!({ "code": code, "message": message });
if let Some(data) = data {
error["data"] = data;
}
json!({ "jsonrpc": "2.0", "id": id, "error": error })
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_revision_we_do_not_implement_is_never_echoed() {
assert_eq!(negotiate(Some("1900-01-01")), LEGACY_NEWEST);
assert_eq!(negotiate(Some("2029-11-05")), LEGACY_NEWEST);
assert_eq!(negotiate(Some(MODERN)), LEGACY_NEWEST);
}
#[test]
fn a_revision_we_do_implement_is_answered_with_itself() {
for revision in ["2025-11-25", "2025-06-18", "2024-11-05"] {
assert_eq!(negotiate(Some(revision)), revision);
}
assert_eq!(negotiate(None), LEGACY_OLDEST);
}
#[test]
fn only_modern_requests_declare_a_version() {
let modern = json!({ "_meta": { META_VERSION: MODERN } });
assert_eq!(declared_version(&modern), Some(MODERN));
assert_eq!(declared_version(&json!({ "name": "semlith_stats" })), None);
}
}