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::sync::Mutex;
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,
session: &mut Session,
) -> 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, session),
))
}
#[derive(Debug, Clone)]
pub struct Session {
pub client: String,
pub id: String,
}
impl Session {
pub fn new(id: impl Into<String>) -> Self {
Session {
client: "mcp".to_string(),
id: id.into(),
}
}
fn who(&self) -> crate::ledger::Who<'_> {
crate::ledger::Who {
client: &self.client,
session: &self.id,
}
}
}
pub const INSTRUCTIONS: &str = "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. \
Prefer semlith_brief for a question about how something works: it answers in \
one call what search, read and neighbors answer in four.";
pub fn serve(
stores: &Mutex<Fleet>,
labels: &str,
input: impl BufRead,
mut output: impl Write,
) -> Result<()> {
stores.lock().unwrap_or_else(|e| e.into_inner()).quiet = true;
let mut session = Session::new(format!("stdio-{}", std::process::id()));
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 = match without_stores(labels, method, ¶ms, &mut session) {
Some(answered) => answered,
None => {
let mut fleet = stores.lock().unwrap_or_else(|e| e.into_inner());
dispatch(&mut fleet, None, method, ¶ms, &mut session)
}
};
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,
session: &mut Session,
) -> Result<Value, Fail> {
if let Some(answered) = without_stores(&stores.labels().join(", "), method, params, session) {
return answered;
}
let declared = declared_version(params);
let modern = declared == Some(MODERN);
match method {
"tools/call" => {
let called = call_tool(stores, writer, params, session)?;
Ok(if modern {
modernize(called, None)
} else {
called
})
}
other => Err((-32601, format!("unknown method: {other}"), None)),
}
}
fn without_stores(
labels: &str,
method: &str,
params: &Value,
session: &mut Session,
) -> Option<Result<Value, Fail>> {
let declared = declared_version(params);
if let Some(version) = declared
&& !SUPPORTED.contains(&version)
{
return Some(Err((
UNSUPPORTED_VERSION,
"Unsupported protocol version".into(),
Some(json!({ "supported": SUPPORTED, "requested": version })),
)));
}
let modern = declared == Some(MODERN);
Some(match method {
"server/discover" => Ok(modernize(
json!({
"supportedVersions": SUPPORTED,
"capabilities": { "tools": {} },
"instructions": INSTRUCTIONS,
}),
Some("public"),
)),
"initialize" => {
if let Some(name) = params
.get("clientInfo")
.and_then(|c| c.get("name"))
.and_then(Value::as_str)
.filter(|n| !n.trim().is_empty())
{
session.client = name.to_string();
}
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(),
"instructions": INSTRUCTIONS,
}))
}
"ping" => Ok(json!({})),
"tools/list" => {
let listed = json!({ "tools": tool_defs(labels) });
Ok(if modern {
modernize(listed, Some("private"))
} else {
listed
})
}
_ => return 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,
}
}
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()
}
pub fn tool_list_bytes() -> usize {
serde_json::to_string(&tool_defs("default"))
.map(|json| json.len())
.unwrap_or(0)
}
pub fn locate_bytes(hits: &[crate::Hit], query: &str) -> usize {
locate(hits, query, DEFAULT_LOCATE_TOKENS).len()
}
fn tool_defs(open: &str) -> Value {
let store_arg = format!("Open: {open}.");
let write_store_arg = format!("Open: {open}. Required when several are.");
json!([
{
"name": "semlith_search",
"description": "Semantic + keyword search. format excerpt adds text.",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": "string" },
"k": { "type": "integer", "description": "Default 8.", "minimum": 1, "maximum": 50 },
"format": { "type": "string", "enum": ["locate", "excerpt"], "description": "Default locate." },
"max_tokens": { "type": "integer", "description": "Default 1500.", "minimum": 200 },
"path": { "type": "array", "description": "Globs; ! excludes (also on ext, lang)." },
"ext": { "type": "array" },
"lang": { "type": "array", "description": "See semlith_languages." },
"prefer": { "type": "string", "enum": ["code", "docs", "any"], "description": "Default any." },
"store": { "type": "array", "description": store_arg }
},
"required": ["query"]
},
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_brief",
"description": "One call: spans, their text, and one-hop callers/callees, under a token budget.",
"inputSchema": {
"type": "object",
"properties": {
"question": { "type": "string" },
"budget": { "type": "integer", "description": "Tokens. Default 4000; text is dropped first.", "minimum": 1 },
"store": { "type": "string" }
},
"required": ["question"]
},
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_read",
"description": "One span or one symbol, nothing around it.",
"inputSchema": {
"type": "object",
"properties": {
"target": { "type": "string", "description": "path:start-end, path:line, or a symbol." },
"store": { "type": "array" }
},
"required": ["target"]
},
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_pattern",
"description": "Tree-sitter pattern over indexed files of one language.",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": "string", "description": "(call_expression function: (identifier) @f)" },
"lang": { "type": "string" },
"path": { "type": "array" },
"offset": { "type": "integer" },
"store": { "type": "array" }
},
"required": ["query", "lang"]
},
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_stats",
"description": "What each open store holds.",
"inputSchema": { "type": "object", "properties": {} },
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_languages",
"description": "The languages lang accepts, and which carry edges.",
"inputSchema": { "type": "object", "properties": {} },
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_files",
"description": "List indexed files: \"not indexed\" is not \"not discussed\".",
"inputSchema": {
"type": "object",
"properties": {
"path": { "type": "array" },
"ext": { "type": "array" },
"lang": { "type": "array" },
"store": { "type": "array" },
"limit": { "type": "integer" }
}
},
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_index",
"description": "Index paths. Only changes are re-embedded.",
"inputSchema": {
"type": "object",
"properties": {
"path": { "type": "array" },
"store": { "type": "string", "description": write_store_arg }
},
"required": ["path"]
},
},
{
"name": "semlith_add",
"description": "Fetch one https URL. No crawling. Refused under --airgap.",
"inputSchema": {
"type": "object",
"properties": {
"url": { "type": "string" },
"store": { "type": "string", "description": write_store_arg }
},
"required": ["url"]
},
"annotations": { "openWorldHint": true }
},
{
"name": "semlith_forget",
"description": "Drop one file from a store.",
"inputSchema": {
"type": "object",
"properties": {
"path": { "type": "string" },
"store": { "type": "string" }
},
"required": ["path"]
},
"annotations": { "destructiveHint": true }
},
{
"name": "semlith_symbol",
"description": "A symbol's definition, callers, callees and the ring beyond.",
"inputSchema": {
"type": "object",
"properties": {
"name": { "type": "string" },
"k": { "type": "integer", "description": "Default 20." },
"history": { "type": "boolean", "description": "What it used to be." },
"store": { "type": "string" }
},
"required": ["name"]
},
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_neighbors",
"description": "What calls a symbol and what it calls. Only resolved edges are certain.",
"inputSchema": {
"type": "object",
"properties": {
"name": { "type": "string" },
"kind": { "type": "array" },
"all": { "type": "boolean", "description": "Expand collapsed rows and missing targets." },
"store": { "type": "string" }
},
"required": ["name"]
},
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_report",
"description": "One of five local reports: savings, access, change, health, gaps.",
"inputSchema": {
"type": "object",
"properties": {
"kind": { "type": "string" },
"format": { "type": "string", "description": "markdown, csv, json, html." },
"model": { "type": "string" },
"store": { "type": "string" }
},
"required": ["kind"]
},
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_impact",
"description": "Everything that reaches a symbol; who would notice a change.",
"inputSchema": {
"type": "object",
"properties": {
"name": { "type": "string" },
"kind": { "type": "array", "items": { "type": "string" } },
"depth": { "type": "integer", "description": "Default 3." },
"all_edges": { "type": "boolean", "description": "Cross ambiguous names; a hypothesis." },
"strict": { "type": "boolean" },
"store": { "type": "string" }
},
"required": ["name"]
},
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_trace",
"description": "A chain between two symbols as evidence: answer, hops, a source line each.",
"inputSchema": {
"type": "object",
"properties": {
"from": { "type": "string" },
"to": { "type": "string" },
"depth": { "type": "integer", "description": "Default 6." },
"all_edges": { "type": "boolean", "description": "Cross ambiguous names; a hypothesis." },
"strict": { "type": "boolean" },
"evidence": { "type": "boolean", "description": "The plain block to paste." },
"store": { "type": "string" }
},
"required": ["from", "to"]
},
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_path",
"description": "The shortest chain between two symbols, or none.",
"inputSchema": {
"type": "object",
"properties": {
"from": { "type": "string" },
"to": { "type": "string" },
"depth": { "type": "integer", "description": "Default 6." },
"all_edges": { "type": "boolean", "description": "Cross ambiguous names; a hypothesis." },
"strict": { "type": "boolean" },
"store": { "type": "string" }
},
"required": ["from", "to"]
},
"annotations": { "readOnlyHint": true }
}
])
}
fn call_tool(
stores: &mut Fleet,
writer: Option<&dyn Writer>,
params: &Value,
session: &Session,
) -> 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 started = std::time::Instant::now();
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 {
crate::fleet::FILTER_SELECTED_NOTHING.to_string()
} else {
let excerpts = args
.get("format")
.and_then(Value::as_str)
.is_some_and(|f| f.eq_ignore_ascii_case("excerpt"));
let max_tokens = args
.get("max_tokens")
.and_then(Value::as_u64)
.map(|v| v as usize)
.unwrap_or(DEFAULT_LOCATE_TOKENS);
let prefer = match args.get("prefer").and_then(Value::as_str) {
Some(raw) => match crate::Prefer::parse(raw) {
Ok(p) => p,
Err(e) => return Ok(tool_error(&e.to_string())),
},
None => crate::Prefer::default(),
};
match stores.search_preferring(Some(&only), query, k.clamp(1, 50), &filter, prefer)
{
Ok(hits) if hits.is_empty() => stores.no_match_reason(&filter),
Ok(hits) if excerpts => {
format!("{}\n{}", reading(query, prefer), render(&hits))
}
Ok(hits) => format!(
"{}\n{}",
reading(query, prefer),
locate(&hits, query, max_tokens)
),
Err(e) => return Ok(tool_error(&e.to_string())),
}
}
}
"semlith_brief" => {
let Some(question) = args.get("question").and_then(Value::as_str) else {
return Err((-32602, "missing required argument: question".into(), None));
};
let only = strings(&args, "store");
let filter = crate::Filter::default();
let budget = args
.get("budget")
.and_then(Value::as_i64)
.unwrap_or(crate::brief::DEFAULT_BUDGET)
.max(1);
match crate::brief::brief(
stores,
Some(&only),
question,
budget,
&filter,
crate::Prefer::default(),
) {
Ok(brief) if brief.spans.is_empty() => stores.no_match_reason(&filter),
Ok(brief) => render_brief(&brief),
Err(e) => return Ok(tool_error(&e.to_string())),
}
}
"semlith_read" => {
let Some(raw) = args.get("target").and_then(Value::as_str) else {
return Err((-32602, "missing required argument: target".into(), None));
};
let target = crate::Target::parse(raw);
let only = strings(&args, "store");
match stores.read_in(Some(&only), &target, &crate::filter::Filter::default()) {
Ok(None) => {
format!("Nothing indexed at {raw:?}. semlith_files says what is indexed.")
}
Ok(Some(crate::Read::Choose(rows))) => {
let mut out = format!("{} definitions of this name:\n", rows.len());
for row in &rows {
out.push_str(&format!(
" {} ({}) {}{}:{}-{}\n",
row.name,
row.kind,
label_of(&row.store),
crate::plain(&row.path),
row.start_line,
row.end_line
));
}
out.trim_end().to_string()
}
Ok(Some(crate::Read::One(span))) => {
let named = match (&span.symbol, &span.symbol_kind) {
(Some(name), Some(kind)) => format!(" · {name} {kind}"),
(Some(name), None) => format!(" · {name}"),
_ => String::new(),
};
format!(
"{}{}:{}-{}{named}{}\n{}",
label_of(&span.store),
crate::plain(&span.path),
span.start_line,
span.end_line,
if span.fresh { "" } else { " · stale" },
span.text
)
}
Err(e) => return Ok(tool_error(&e.to_string())),
}
}
"semlith_pattern" => {
let Some(query) = args.get("query").and_then(Value::as_str) else {
return Err((-32602, "missing required argument: query".into(), None));
};
let Some(lang) = args.get("lang").and_then(Value::as_str) else {
return Err((-32602, "missing required argument: lang".into(), None));
};
let only = strings(&args, "store");
let filter = match filter_of(&args) {
Ok(f) => f,
Err(e) => return Ok(tool_error(&e)),
};
let offset = args
.get("offset")
.and_then(Value::as_u64)
.map(|n| n as usize)
.unwrap_or(0);
match stores.pattern_in(Some(&only), lang, query, &filter, offset) {
Ok(found) if found.files == 0 => {
format!("No indexed file is {}.", found.language)
}
Ok(found) if found.matches.is_empty() => {
format!("No match in {} {} files.", found.files, found.language)
}
Ok(found) => {
let mut out = String::new();
for m in &found.matches {
out.push_str(&format!(
"{}{}:{}-{} @{} {}\n",
label_of(&m.store),
crate::plain(&m.path),
m.start_line,
m.end_line,
m.capture,
m.text
));
}
if found.truncated {
out.push_str(&format!(
"truncated at {} matches — call again with offset: {} for the rest\n",
found.matches.len(),
offset + found.matches.len()
));
}
out.trim_end().to_string()
}
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 mut body = format!(
"{files} files, {chunks} chunks, {} indexed, model {} ({} dim)",
crate::human_bytes(bytes),
store.model(),
store.dim(),
);
match crate::store::coverage_by_language(store.db()) {
Ok(coverage) => {
for row in coverage.iter().filter(|r| r.definitions > 0) {
body.push_str(&format!(
"\n {}: {} files ({} unparsed), {} definitions, call edges \
{} extracted / {} resolved / {} ambiguous / {} unresolved \
({} % settled)",
row.language,
row.files,
row.parser_failed,
row.definitions,
row.extracted,
row.resolved,
row.ambiguous,
row.unresolved,
row.settled_share(),
));
}
}
Err(e) => return Ok(tool_error(&format!("stats failed: {e}"))),
}
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
.iter()
.map(|p| crate::plain(p))
.collect::<Vec<_>>()
.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 (roots, unreadable) = crate::check_roots(&roots);
if !unreadable.is_empty() {
let named = unreadable
.iter()
.map(|(path, why)| format!("{}: {why}", path.display()))
.collect::<Vec<_>>()
.join("; ");
return Ok(tool_error(&format!("cannot index {named}")));
}
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 by_reason = if report.skipped_reasons.is_empty() {
String::new()
} else {
let mut parts: Vec<(usize, &str)> = report
.skipped_reasons
.iter()
.map(|(kind, n)| (*n, kind.as_str()))
.collect();
parts.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(b.1)));
format!(
" ({})",
parts
.iter()
.map(|(n, kind)| format!("{n} {kind}"))
.collect::<Vec<_>>()
.join(", ")
)
};
let mut out = format!(
"{} indexed, {} unchanged, {} skipped{by_reason}, {} 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: {} — {why}", crate::plain(path)));
}
for (path, why) in &report.failed {
out.push_str(&format!("\nfailed: {} — {why}", crate::plain(path)));
}
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");
if args
.get("history")
.and_then(Value::as_bool)
.unwrap_or(false)
{
match stores.past_in(Some(&only), name, k.clamp(1, 200)) {
Ok(past) if past.is_empty() => format!(
"No earlier definition of {name} is recorded. \
A store keeps history from its first index pass under 0.23.0."
),
Ok(past) => past
.iter()
.map(|row| {
format!(
"{} {} {}:{}-{} until {} hash {}",
row.kind,
row.qualified,
crate::plain(&row.path),
row.start_line,
row.end_line,
crate::clock::local_stamp(row.retired_at),
&row.content_hash[..row.content_hash.len().min(12)],
)
})
.collect::<Vec<_>>()
.join("\n"),
Err(e) => return Ok(tool_error(&e.to_string())),
}
} else {
match stores.evidence_in(
Some(&only),
name,
&crate::graph::dependency_kinds(),
k.clamp(1, 200),
false,
) {
Ok(found) if found.definitions.is_empty() => empty_graph(stores, name),
Ok(found) => found.render("", "", &crate::plain),
Err(e) => return Ok(tool_error(&e.to_string())),
}
}
}
"semlith_languages" => {
let with_edges: Vec<&str> = crate::graph::languages();
let names: Vec<String> = crate::filter::LANGUAGES
.iter()
.map(|entry| {
if with_edges.contains(&entry.name) {
format!("{} (graph)", entry.name)
} else {
entry.name.to_string()
}
})
.collect();
format!(
"{} languages. Those marked (graph) also carry symbols and edges, so \
semlith_symbol, semlith_neighbors and semlith_path work on them.\n{}",
names.len(),
names.join(", "),
)
}
"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");
let all = args.get("all").and_then(Value::as_bool).unwrap_or(false);
match stores.neighbours_in(Some(&only), name, &kinds, all) {
Ok(n)
if n.callers.is_empty()
&& n.callees.is_empty()
&& n.hidden == 0
&& n.unresolved.is_empty() =>
{
empty_graph(stores, name)
}
Ok(n) => {
let mut body = format!(
"callers of {name} ({}):\n{}\n\ncallees of {name} ({}):\n{}",
n.callers.len(),
render_ends(&n.callers),
n.callees.len(),
render_ends(&n.callees),
);
if n.hidden > 0 {
body.push_str(&format!(
"\n\n{} target{} outside this store, not listed. Ask with all: true.",
n.hidden,
if n.hidden == 1 { "" } else { "s" },
));
}
if !n.unresolved.is_empty() {
let outside = n
.unresolved
.iter()
.map(|u| format!(" {} via {}", u.name, u.kind))
.collect::<Vec<_>>()
.join("\n");
body.push_str(&format!("\n\noutside this store:\n{outside}"));
}
body
}
Err(e) => return Ok(tool_error(&e.to_string())),
}
}
"semlith_report" => {
let Some(kind) = args.get("kind").and_then(Value::as_str) else {
return Err((-32602, "missing required argument: kind".into(), None));
};
let format = args
.get("format")
.and_then(Value::as_str)
.unwrap_or("markdown");
let model = args
.get("model")
.and_then(Value::as_str)
.unwrap_or("Sonnet 5");
match crate::report::generate(stores, kind, model).and_then(|r| r.render(format)) {
Ok(text) => text,
Err(e) => return Ok(tool_error(&e.to_string())),
}
}
"semlith_impact" => {
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");
let depth = args.get("depth").and_then(Value::as_u64).unwrap_or(3) as u32;
let depth = depth.clamp(1, 10);
let only = strings(&args, "store");
let strict = args.get("strict").and_then(Value::as_bool).unwrap_or(false);
let all_edges = args
.get("all_edges")
.and_then(Value::as_bool)
.unwrap_or(false)
&& !strict;
match stores.impact_in(Some(&only), name, &kinds, depth, all_edges) {
Ok(impact) => impact.render("", "", &crate::plain),
Err(e) => return Ok(tool_error(&e.to_string())),
}
}
"semlith_trace" => {
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 depth = depth.clamp(1, 20);
let only = strings(&args, "store");
let strict = args.get("strict").and_then(Value::as_bool).unwrap_or(false);
let all_edges = args
.get("all_edges")
.and_then(Value::as_bool)
.unwrap_or(false)
&& !strict;
let evidence = args
.get("evidence")
.and_then(Value::as_bool)
.unwrap_or(false);
match stores.trace_in(Some(&only), from, to, depth, all_edges, &crate::plain) {
Ok(trace) => {
if evidence {
trace.evidence(&crate::plain)
} else {
trace.render("", "", &crate::plain)
}
}
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");
let strict = args.get("strict").and_then(Value::as_bool).unwrap_or(false);
let all_edges = args
.get("all_edges")
.and_then(Value::as_bool)
.unwrap_or(false)
&& !strict;
let depth = depth.clamp(1, 20);
match stores.path_in(Some(&only), from, to, depth, all_edges) {
Ok(Some(chain)) if chain.steps.is_empty() => format!("{from} is {to}."),
Ok(Some(chain)) => chain.render("", "", &crate::plain),
Ok(None) => crate::graph::not_connected(from, to, depth, all_edges),
Err(e) => return Ok(tool_error(&e.to_string())),
}
}
other => return Err((-32602, format!("unknown tool: {other}"), None)),
};
record(stores, session, name, &args, &body, started.elapsed());
Ok(json!({ "content": [{ "type": "text", "text": body }] }))
}
fn record(
stores: &Fleet,
session: &Session,
tool: &str,
args: &Value,
body: &str,
elapsed: std::time::Duration,
) {
let who = session.who();
match tool {
"semlith_search" => {
let Some(query) = args.get("query").and_then(Value::as_str) else {
return;
};
crate::ledger::reply(stores, &who, "search", query, body, elapsed);
}
"semlith_brief" => {
let Some(question) = args.get("question").and_then(Value::as_str) else {
return;
};
crate::ledger::reply(stores, &who, "brief", question, body, elapsed);
}
"semlith_pattern" => {
let subject = args.get("query").and_then(Value::as_str).unwrap_or("");
let found = !body.starts_with("No match") && !body.starts_with("No indexed");
crate::ledger::graph(stores, &who, "pattern", subject, "", found, elapsed);
}
"semlith_read" => {
let subject = args.get("target").and_then(Value::as_str).unwrap_or("");
let found = !body.starts_with("Nothing indexed");
crate::ledger::graph(stores, &who, "read", subject, "", found, elapsed);
}
"semlith_neighbors" | "semlith_path" | "semlith_symbol" => {
let Some(subject) = args
.get("name")
.or_else(|| args.get("from"))
.and_then(Value::as_str)
else {
return;
};
let short = tool.trim_start_matches("semlith_");
let found = !body.contains("not connected") && !body.contains("nothing");
crate::ledger::graph(stores, &who, short, subject, body, found, elapsed);
}
_ => {}
}
}
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| {
if e.confidence == crate::graph::AMBIGUOUS {
return format!(
" {} via {} ({}) · {} definitions{}",
e.symbol.name,
e.kind,
e.confidence,
e.definitions,
crate::graph::call_site(e, &crate::plain)
);
}
format!(
" {} via {} ({}) {}{}:{}{}",
e.symbol.name,
e.kind,
e.confidence,
label_of(&e.symbol.store),
crate::plain(&e.symbol.path),
e.symbol.start_line,
crate::graph::call_site(e, &crate::plain)
)
})
.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 locate(hits: &[crate::Hit], query: &str, max_tokens: usize) -> String {
if hits.is_empty() {
return String::new();
}
let budget = max_tokens.max(MIN_LOCATE_TOKENS);
let terms: Vec<String> = query
.split(|c: char| !c.is_alphanumeric() && c != '_')
.filter(|t| t.len() > 2)
.map(|t| t.to_lowercase())
.collect();
let mut files: Vec<(String, Vec<&crate::Hit>)> = Vec::new();
for hit in hits {
let label = match &hit.store {
Some(store) => format!("{store} {}", crate::plain(&hit.path)),
None => crate::plain(&hit.path),
};
match files.iter_mut().find(|(name, _)| *name == label) {
Some((_, group)) => group.push(hit),
None => files.push((label, vec![hit])),
}
}
let mut blocks: Vec<(usize, String)> = Vec::new();
for (label, group) in &files {
let mut block = format!("{label}\n");
for hit in group {
block.push_str(&locate_row(hit, &terms));
}
blocks.push((group.len(), block));
}
let total = hits.len();
let mut out = String::new();
let mut shown = 0usize;
for (count, block) in blocks {
if !out.is_empty() && tokens(&out) + tokens(&block) > budget {
break;
}
out.push_str(&block);
shown += count;
}
if shown < total {
out.push_str(&format!("truncated: {shown} of {total}\n"));
}
out.trim_end().to_string()
}
fn reading(query: &str, prefer: crate::Prefer) -> String {
let shape = crate::shape_of(query);
match prefer {
crate::Prefer::Any => format!("{} · {}", shape.as_str(), shape.weighting()),
chosen => format!(
"{} · {} · prefer {}",
shape.as_str(),
shape.weighting(),
chosen.as_str()
),
}
}
fn locate_row(hit: &crate::Hit, terms: &[String]) -> String {
if let Some(px) = hit.image {
return format!(" image {}x{} px\n", px.width, px.height);
}
let mut marks = Vec::new();
if !hit.lists.is_empty() {
match (&hit.provenance, hit.lists.as_slice()) {
(Some(tier), ["graph"]) => marks.push(format!("graph({tier})")),
_ => marks.push(hit.lists.join("+")),
}
}
if !hit.fresh {
marks.push("stale".to_string());
}
let named = match (&hit.symbol, &hit.symbol_kind) {
(Some(name), Some(kind)) => format!(" \u{00b7} {name} {kind}"),
(Some(name), None) => format!(" \u{00b7} {name}"),
_ => String::new(),
};
let marked = if marks.is_empty() {
String::new()
} else {
format!(" \u{00b7} {}", marks.join(" \u{00b7} "))
};
format!(
" {}-{}{named}{marked}\n {}\n",
hit.start_line,
hit.end_line,
best_line(&hit.text, terms),
)
}
fn best_line(text: &str, terms: &[String]) -> String {
const WIDTH: usize = 96;
let scored = text
.lines()
.map(|line| {
let lowered = line.to_lowercase();
let hits = terms.iter().filter(|t| lowered.contains(*t)).count();
(hits, line.trim())
})
.filter(|(_, line)| !line.is_empty())
.max_by_key(|(hits, _)| *hits);
let line = match scored {
Some((0, _)) | None => text.lines().map(str::trim).find(|l| !l.is_empty()),
Some((_, line)) => Some(line),
}
.unwrap_or_default();
if line.chars().count() <= WIDTH {
return line.to_string();
}
let cut: String = line.chars().take(WIDTH).collect();
format!("{cut}\u{2026}")
}
const MIN_LOCATE_TOKENS: usize = 200;
const DEFAULT_LOCATE_TOKENS: usize = 1_500;
fn tokens(text: &str) -> usize {
text.len().div_ceil(4)
}
fn render_brief(brief: &crate::brief::Brief) -> String {
let mut out = String::new();
for span in &brief.spans {
let via = if span.lists.is_empty() {
String::new()
} else {
format!(" [{}]", span.lists.join("+"))
};
let what = match &span.symbol {
Some(name) => format!(" {name}"),
None => String::new(),
};
out.push_str(&format!(
"{}:{}-{}{what}{via}\n",
span.path, span.start_line, span.end_line
));
match &span.text {
Some(text) => {
for line in text.lines() {
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
}
None => out.push_str(" (text left out for the budget)\n"),
}
}
for symbol in &brief.symbols {
out.push_str(&format!("{} [graph]\n", symbol.name));
for edge in &symbol.callers {
out.push_str(&format!(
" called by {} {}:{}\n",
edge.symbol.name, edge.symbol.path, edge.symbol.start_line
));
}
for edge in &symbol.callees {
out.push_str(&format!(
" calls {} {}:{}\n",
edge.symbol.name, edge.symbol.path, edge.symbol.start_line
));
}
if symbol.hidden > 0 {
out.push_str(&format!(" and {} more edges\n", symbol.hidden));
}
}
out.push_str(&format!(
"{} tokens of {}, counted with {}",
brief.tokens, brief.budget, brief.counted_with
));
if !brief.cut.is_empty() {
let mut dropped = Vec::new();
if brief.cut.span_text > 0 {
dropped.push(format!("{} spans left without text", brief.cut.span_text));
}
if brief.cut.symbols > 0 {
dropped.push(format!("{} symbols' edges", brief.cut.symbols));
}
out.push_str(&format!(" -- dropped: {}", dropped.join(", ")));
}
out.push('\n');
out
}
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,
crate::plain(&h.path),
px.width,
px.height,
h.score,
));
continue;
}
out.push_str(&format!(
"[{}] {from}{}:{}-{} (score {:.3}{via})\n{}\n\n",
i + 1,
crate::plain(&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);
}
fn hit(path: &str, start: u32, end: u32, text: &str) -> crate::Hit {
crate::Hit {
score: 0.5,
path: path.to_string(),
start_line: start,
end_line: end,
text: text.to_string(),
store: None,
lists: vec!["vector", "keyword"],
image: None,
fresh: true,
symbol: Some("edges_out".to_string()),
symbol_kind: Some("fn".to_string()),
provenance: None,
}
}
#[test]
fn a_locate_row_is_an_address_rather_than_its_contents() {
let hits: Vec<crate::Hit> = (0..8)
.map(|i| {
hit(
&format!("src/file{i}.rs"),
100 + i * 10,
120 + i * 10,
"fn edges_out(db: &Connection, name: &str) -> Result<Vec<EdgeEnd>> {\n\
let filter = kind_predicate(kinds, \"e.kind\");\n\
// A long chunk of source, of the kind 0.14.0 sent whole.\n",
)
})
.collect();
let reply = locate(&hits, "edges_out", DEFAULT_LOCATE_TOKENS);
let per_hit = reply.len() / hits.len();
assert!(
per_hit < 200,
"a locate row costs {per_hit} bytes; the whole reply was:\n{reply}"
);
assert!(reply.contains("edges_out fn"), "{reply}");
assert!(reply.contains("100-120"), "{reply}");
}
#[test]
fn a_small_budget_cuts_the_reply_and_says_it_did() {
let hits: Vec<crate::Hit> = (0..40)
.map(|i| hit(&format!("src/file{i}.rs"), 10, 40, "fn a() { helper(); }\n"))
.collect();
let full = locate(&hits, "helper", 10_000);
assert!(!full.contains("truncated:"), "nothing was cut: {full}");
let cut = locate(&hits, "helper", MIN_LOCATE_TOKENS);
assert!(cut.contains("truncated:"), "{cut}");
assert!(cut.len() < full.len(), "the budget did not cut anything");
assert!(
cut.contains("of 40"),
"the reader is told what the total was: {cut}"
);
}
#[test]
fn an_impossible_budget_still_returns_the_best_file() {
let hits = vec![hit("src/store.rs", 948, 970, "fn edges_out() {}\n")];
let reply = locate(&hits, "edges_out", 1);
assert!(reply.contains("src/store.rs"), "{reply}");
}
#[test]
fn a_stale_hit_is_marked_and_a_graph_reached_one_carries_its_tier() {
let mut stale = hit("src/lib.rs", 10, 20, "fn a() {}\n");
stale.fresh = false;
let mut reached = hit("src/graph.rs", 30, 40, "fn b() {}\n");
reached.lists = vec!["graph"];
reached.provenance = Some(crate::graph::RESOLVED.to_string());
let reply = locate(&[stale, reached], "a", DEFAULT_LOCATE_TOKENS);
assert!(reply.contains("stale"), "{reply}");
assert!(reply.contains("graph(resolved)"), "{reply}");
}
#[test]
fn the_tool_list_stays_small() {
let size = serde_json::to_string(&tool_defs("default")).unwrap().len();
let each: Vec<String> = tool_defs("default")
.as_array()
.unwrap()
.iter()
.map(|t| {
format!(
"{}={}",
t["name"].as_str().unwrap(),
serde_json::to_string(t).unwrap().len()
)
})
.collect();
assert!(
size <= 6_396,
"tools/list is {size} bytes, over the 6 396 the 1 600-token gate allows: {}",
each.join(" ")
);
}
}