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 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, 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, 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, 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 {
let open = stores.labels().join(", ");
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_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
}
}
])
}
fn call_tool(stores: &mut Fleet, 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));
}
let store = match stores.writable(&strings(&args, "store")) {
Ok(s) => s,
Err(e) => return Ok(tool_error(&e.to_string())),
};
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
));
}
out
}
}
}
"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(", ")
)));
}
};
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) => format!("{path} was not indexed; nothing removed."),
Ok(n) => format!("Removed {n} chunks for {path}."),
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 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(),
};
out.push_str(&format!(
"[{}] {from}{}:{}-{} (score {:.3})\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<()> {
let msg = 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 })
}
};
writeln!(out, "{msg}")?;
out.flush()
}
#[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);
}
}