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),
))
}
pub fn answer_unlocked(writer: &dyn Writer, request: &Value, session: &Session) -> Option<Value> {
let id = request.get("id").cloned()?;
if request.get("method").and_then(Value::as_str) != Some("tools/call") {
return None;
}
let params = request.get("params").cloned().unwrap_or(json!({}));
let name = params.get("name").and_then(Value::as_str).unwrap_or("");
if !session.writes || !matches!(name, "semlith_index" | "semlith_add" | "semlith_forget") {
return None;
}
let mut empty = Fleet::empty();
let mut session = session.clone();
Some(reply(
&id,
dispatch(
&mut empty,
Some(writer),
"tools/call",
¶ms,
&mut session,
),
))
}
#[derive(Debug, Clone)]
pub struct Session {
pub client: String,
pub version: String,
pub id: String,
pub host: Option<String>,
pub writes: bool,
}
impl Session {
pub fn new(id: impl Into<String>) -> Self {
Session {
client: "mcp".to_string(),
version: String::new(),
id: id.into(),
host: None,
writes: false,
}
}
fn who(&self) -> crate::ledger::Who<'_> {
crate::ledger::Who {
client: &self.client,
session: &self.id,
version: &self.version,
}
}
}
pub const INSTRUCTIONS_LIMIT: usize = 600;
pub fn instructions(roots: &[std::path::PathBuf]) -> String {
const ROUTES: &str = " For a code question there, use semlith before grep, rg, find or cat: \
semlith_search to locate (exact: true for every line matching a regex; path to scope \
to one repository), semlith_read for a span or a whole definition, semlith_neighbors \
for who calls a symbol, semlith_impact for what breaks if it changes, semlith_brief \
for a span plus its callers in one call. Paths are relative to the root named above.";
let lead = "semlith indexes ";
let mut named = String::new();
let mut left = roots.len();
for root in roots {
let text = crate::plain(&root.to_string_lossy());
let more = match left - 1 {
0 => String::new(),
1 => " and 1 more folder".to_string(),
n => format!(" and {n} more folders"),
};
let sep = if named.is_empty() { "" } else { ", " };
if lead.len() + named.len() + sep.len() + text.len() + more.len() + 1 + ROUTES.len()
> INSTRUCTIONS_LIMIT
{
break;
}
named.push_str(sep);
named.push_str(&text);
left -= 1;
}
if named.is_empty() && left == 0 {
return format!("semlith searches the local stores this server was opened on.{ROUTES}");
}
if left > 0 {
let sep = if named.is_empty() { "" } else { " and " };
named.push_str(&format!(
"{sep}{left} more folder{}",
if left == 1 { "" } else { "s" }
));
}
format!("{lead}{named}.{ROUTES}")
}
pub fn serve(
stores: &Mutex<Fleet>,
labels: &str,
input: impl BufRead,
mut output: impl Write,
) -> Result<()> {
let roots = {
let mut fleet = stores.lock().unwrap_or_else(|e| e.into_inner());
fleet.quiet = true;
fleet.roots()
};
let mut session = Session::new(format!("stdio-{}", std::process::id()));
session.writes = crate::home::agent_may_write();
session.host = crate::clientid::host().map(str::to_string);
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, &roots, 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(", "),
&stores.roots(),
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,
roots: &[std::path::PathBuf],
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(roots),
}),
Some("public"),
)),
"initialize" => {
let info = params.get("clientInfo");
session.client = crate::clientid::label(
info.and_then(|c| c.get("name")).and_then(Value::as_str),
session.host.as_deref(),
);
let name = info.and_then(|c| c.get("name")).and_then(Value::as_str);
if let Some(version) = info
.and_then(|c| c.get("version"))
.and_then(Value::as_str)
.filter(|_| !name.is_some_and(crate::clientid::generic))
{
session.version = version.chars().take(64).collect();
}
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(roots),
}))
}
"ping" => Ok(json!({})),
"tools/list" => {
let listed = json!({ "tools": for_scope(tool_defs(labels), session.writes) });
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 listed_names() -> Vec<String> {
tool_defs("")
.as_array()
.into_iter()
.flatten()
.filter_map(|t| t["name"].as_str().map(str::to_string))
.collect()
}
pub fn tool_list() -> Vec<(String, String)> {
all_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, &crate::plain).len()
}
fn tool_defs(open: &str) -> Value {
let all = all_tool_defs(open);
if std::env::var("SEMLITH_MCP_TOOLS").is_ok_and(|v| v == "all") {
return all;
}
Value::Array(
all.as_array()
.into_iter()
.flatten()
.filter(|t| LISTED.contains(&t["name"].as_str().unwrap_or_default()))
.cloned()
.collect(),
)
}
pub const READ_TOOLS: &[&str] = &[
"semlith_search",
"semlith_brief",
"semlith_read",
"semlith_files",
"semlith_symbol",
"semlith_neighbors",
"semlith_impact",
"semlith_stats",
"semlith_languages",
"semlith_report",
"semlith_pattern",
"semlith_path",
"semlith_trace",
];
fn for_scope(defs: Value, writes: bool) -> Value {
if writes {
return defs;
}
Value::Array(
defs.as_array()
.into_iter()
.flatten()
.filter(|t| READ_TOOLS.contains(&t["name"].as_str().unwrap_or_default()))
.cloned()
.collect(),
)
}
pub fn scope_refusal(tool: &str) -> String {
format!(
"{tool} changes the index, and this agent key is read-only. The owner can grant \
write in the portal (Agents > Agent key > Allow writes) or with \
`semlith key --scope write`."
)
}
pub const LISTED: &[&str] = &[
"semlith_search",
"semlith_brief",
"semlith_read",
"semlith_files",
"semlith_symbol",
"semlith_neighbors",
"semlith_impact",
"semlith_stats",
];
fn all_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": "Where is X / how does X work: ranked file:line spans. exact: true = every matching line (grep -E). Also ext, lang, prefer (code|docs), format: excerpt.",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": "string" },
"k": { "type": "integer", "description": "Default 8." },
"path": { "type": "array", "items": { "type": "string" }, "description": "Globs; ! excludes." },
"exact": { "type": "boolean" },
"local": { "type": "boolean" },
"store": { "type": "array", "items": { "type": "string" }, "description": store_arg }
},
"required": ["query"]
},
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_brief",
"description": "Spans, the top span's text and its callers/callees, in one call.",
"inputSchema": {
"type": "object",
"properties": {
"question": { "type": "string" },
"path": { "type": "array", "items": { "type": "string" } },
"store": { "type": "string" }
},
"required": ["question"]
},
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_read",
"description": "Read path:start-end, path:line, or a symbol (Type::method) whole.",
"inputSchema": {
"type": "object",
"properties": {
"target": { "type": "string" },
"store": { "type": "array", "items": { "type": "string" } }
},
"required": ["target"]
},
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_pattern",
"description": "What shape does X take: a tree-sitter query over one language's indexed files.",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": "string", "description": "(call_expression function: (identifier) @f)" },
"lang": { "type": "string" },
"path": { "type": "array", "items": { "type": "string" } },
"offset": { "type": "integer" },
"store": { "type": "array", "items": { "type": "string" } }
},
"required": ["query", "lang"]
},
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_stats",
"description": "What is indexed, per store.",
"inputSchema": { "type": "object", "properties": {} },
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_languages",
"description": "Which languages the lang filter accepts, and which carry a graph.",
"inputSchema": { "type": "object", "properties": {} },
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_files",
"description": "What is in this folder; tree: true for a directory view with symbols.",
"inputSchema": {
"type": "object",
"properties": {
"path": { "type": "array", "items": { "type": "string" } },
"tree": { "type": "boolean" },
"store": { "type": "array", "items": { "type": "string" } }
}
},
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_index",
"description": "Index paths, only when the user asks. Only changes re-embed.",
"inputSchema": {
"type": "object",
"properties": {
"path": { "type": "array", "items": { "type": "string" } },
"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, only when the user asks.",
"inputSchema": {
"type": "object",
"properties": {
"path": { "type": "string" },
"store": { "type": "string" }
},
"required": ["path"]
},
"annotations": { "destructiveHint": true }
},
{
"name": "semlith_symbol",
"description": "Where X is defined, with callers and callees. names: up to 20, a row each.",
"inputSchema": {
"type": "object",
"properties": {
"name": { "type": "string" },
"names": { "type": "array", "items": { "type": "string" } },
"path": { "type": "array", "items": { "type": "string" } },
"store": { "type": "string" }
}
},
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_neighbors",
"description": "Who calls X, and what X calls. Use for caller questions.",
"inputSchema": {
"type": "object",
"properties": {
"name": { "type": "string" },
"path": { "type": "array", "items": { "type": "string" } },
"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": "What breaks if X changes: every call site, transitively. Type::method narrows.",
"inputSchema": {
"type": "object",
"properties": {
"name": { "type": "string" },
"path": { "type": "array", "items": { "type": "string" } },
"store": { "type": "string" }
},
"required": ["name"]
},
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_trace",
"description": "How does A reach B: the chain, a source line per hop.",
"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": "Is A connected to B: the shortest chain, 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 }
}
])
}
struct Remote {
fetched: crate::cloud::Fetched,
skip_local: bool,
}
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("");
if !session.writes && !READ_TOOLS.contains(&name) {
crate::ledger::refusal(stores, &session.who(), name);
return Ok(json!({
"content": [{ "type": "text", "text": scope_refusal(name) }],
"isError": true,
}));
}
let remotes = crate::cloud::remote_stores();
if remotes.is_empty() {
return call_local(stores, writer, params, session, None);
}
call_with_remotes(stores, writer, params, session, &remotes)
}
fn call_with_remotes(
stores: &mut Fleet,
writer: Option<&dyn Writer>,
params: &Value,
session: &Session,
remotes: &[(String, crate::home::Remote)],
) -> 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 named = strings(&args, "store");
let is_remote = |n: &String| remotes.iter().any(|(r, _)| r == n);
let (remote_named, local_named): (Vec<String>, Vec<String>) =
named.iter().cloned().partition(is_remote);
let skip_local = !named.is_empty() && local_named.is_empty();
let who = crate::cloud::Who {
client: &session.client,
session: &session.id,
};
let local_params = || {
let mut p = params.clone();
if !named.is_empty() {
p["arguments"]["store"] = json!(local_named);
}
p
};
let exact = args.get("exact").and_then(Value::as_bool) == Some(true);
let local_only = args.get("local").and_then(Value::as_bool) == Some(true);
let targets: Vec<(String, crate::home::Remote)> = remotes
.iter()
.filter(|(r, _)| {
if named.is_empty() {
name == "semlith_search" && !local_only
} else {
remote_named.contains(r)
}
})
.cloned()
.collect();
if targets.is_empty()
|| matches!(
name,
"semlith_index" | "semlith_forget" | "semlith_add" | "semlith_stats"
)
{
return call_local(stores, writer, params, session, None);
}
if name == "semlith_search" && !exact {
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 fetched = crate::cloud::search(
&targets,
&crate::cloud::Query {
query,
k: k.clamp(1, 50),
path: &strings(&args, "path"),
ext: &strings(&args, "ext"),
lang: &strings(&args, "lang"),
prefer: args.get("prefer").and_then(Value::as_str),
},
&who,
);
let remote = Remote {
fetched,
skip_local,
};
return call_local(stores, writer, &local_params(), session, Some(&remote));
}
let mut parts: Vec<String> = Vec::new();
let mut failed = false;
if !skip_local {
let local = call_local(stores, writer, &local_params(), session, None)?;
failed = local["isError"] == json!(true);
parts.push(text_of(&local));
}
let mut skipped = Vec::new();
for (store, remote) in &targets {
match crate::cloud::forward_tool(store, remote, name, &args, &who) {
Ok(text) => parts.push(format!("{store} ({})\n{text}", crate::cloud::badge(remote))),
Err(why) => skipped.push(why),
}
}
if let Some(line) = crate::cloud::skipped_line(&skipped) {
parts.push(line);
}
let mut out = json!({ "content": [{ "type": "text", "text": parts.join("\n\n") }] });
if failed && skip_local {
out["isError"] = json!(true);
}
Ok(out)
}
fn text_of(result: &Value) -> String {
result["content"]
.as_array()
.into_iter()
.flatten()
.filter_map(|c| c["text"].as_str())
.collect::<Vec<_>>()
.join("\n")
}
fn call_local(
stores: &mut Fleet,
writer: Option<&dyn Writer>,
params: &Value,
session: &Session,
remote: Option<&Remote>,
) -> 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();
if matches!(name, "semlith_index" | "semlith_forget" | "semlith_add") {
for store in strings(&args, "store") {
if let Err(e) = crate::home::refuse_remote(&store) {
return Ok(tool_error(&e.to_string()));
}
}
}
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() || remote.is_some() {
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 if args.get("exact").and_then(Value::as_bool) == Some(true) {
let offset = args.get("offset").and_then(Value::as_u64).unwrap_or(0) as usize;
match stores.grep_in(Some(&only), query, &filter, offset) {
Ok(found) => {
let paths = stores.shortener();
paths.with_header(render_grep(
&found,
offset,
max_tokens(&args) * 4,
&|p| paths.short(p),
))
}
Err(e) => return Ok(tool_error(&e.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 = max_tokens(&args);
let asked = match args.get("prefer").and_then(Value::as_str) {
Some(raw) => match crate::Prefer::parse(raw) {
Ok(p) => Some(p),
Err(e) => return Ok(tool_error(&e.to_string())),
},
None => None,
};
let prefer = asked.unwrap_or_else(|| match only.as_slice() {
[one] => stores.lean(one).unwrap_or_default(),
_ => crate::Prefer::default(),
});
let found = match remote {
Some(r) if r.skip_local => Ok(Vec::new()),
_ => stores.search_leaning(Some(&only), query, k.clamp(1, 50), &filter, asked),
};
let found = match (found, remote) {
(Ok(hits), Some(r)) => {
let label = (stores.len() == 1)
.then(|| stores.labels().first().map(|l| l.to_string()))
.flatten();
Ok(crate::cloud::merge(
hits,
label.as_deref(),
&r.fetched.hits,
k.clamp(1, 50),
))
}
(found, _) => found,
};
let skipped = remote.and_then(|r| crate::cloud::skipped_line(&r.fetched.skipped));
let answer = match found {
Ok(hits) if hits.is_empty() && remote.is_some_and(|r| r.skip_local) => {
"No match in the remote stores.".to_string()
}
Ok(hits) if hits.is_empty() => stores.no_match_reason(&filter),
Ok(hits) if excerpts => {
format!("{}\n{}", reading(query, prefer), render(&hits))
}
Ok(hits) => search_reply(stores, &hits, query, prefer, max_tokens).text,
Err(e) => return Ok(tool_error(&e.to_string())),
};
match skipped {
Some(line) => format!("{answer}\n{line}"),
None => answer,
}
}
}
"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 = match filter_of(&args) {
Ok(f) => f,
Err(e) => return Ok(tool_error(&e)),
};
let budget = args
.get("budget")
.and_then(Value::as_i64)
.unwrap_or(crate::brief::DEFAULT_BUDGET)
.max(1);
match crate::brief::brief_leaning(stores, Some(&only), question, budget, &filter, None)
{
Ok(brief) => brief_reply(stores, &brief, &filter),
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");
let paths = stores.shortener();
let one = |span: &crate::Span| {
let named = match (&span.symbol, &span.symbol_kind) {
(Some(name), Some(kind)) => format!(" · {name} {kind}"),
(Some(name), None) => format!(" · {name}"),
_ => String::new(),
};
let state = if span.replaced {
" · stale; file replaced since indexing (not a regular file now), not read"
} else if span.from_disk {
" · read from disk, not yet re-indexed"
} else if !span.fresh {
" · stale"
} else {
""
};
format!(
"{}{}:{}-{}{named}{state}\n{}",
label_of(&span.store),
paths.short(&span.path),
span.start_line,
span.end_line,
span.text
)
};
let read = stores.read_in(Some(&only), &target, &crate::filter::Filter::default());
let whole_file = |span: &crate::Span| match &target {
crate::Target::Symbol(name) => {
let path = crate::plain(&span.path);
let name = name.trim_start_matches("./");
path.len() > name.len()
&& path.ends_with(name)
&& path[..path.len() - name.len()].ends_with(['/', '\\'])
}
_ => false,
};
let body = match read {
Ok(Some(crate::Read::One(span)))
if whole_file(&span) && span.text.len() > WHOLE_FILE_CHARS =>
{
let defs = stores
.outline_in(span.store.as_ref().map(std::slice::from_ref), &span.path)
.unwrap_or_default();
let mut out = format!(
"{}{} is {} lines, {} characters; its definitions, to read by path:start-end or by name:",
label_of(&span.store),
paths.short(&span.path),
span.end_line,
span.text.len()
);
for (start, end, name, kind) in &defs {
out.push_str(&format!("\n {start}-{end} {kind} {name}"));
}
if defs.is_empty() {
out.push_str("\n none parsed; ask for a line range");
}
out
}
Ok(None) => {
format!("Nothing indexed at {raw:?}. semlith_files says what is indexed.")
}
Ok(Some(crate::Read::Choose(rows))) if rows.len() <= READ_WHOLE => {
let mut out = String::new();
let mut left = rows.len();
for row in &rows {
let span = crate::Target::Span {
path: row.path.clone(),
start: row.start_line,
end: row.end_line,
};
let text = match stores.read_in(
row.store.as_ref().map(std::slice::from_ref),
&span,
&crate::filter::Filter::default(),
) {
Ok(Some(crate::Read::One(span))) => one(&span),
_ => continue,
};
if !out.is_empty() && out.len() + text.len() > READ_CHARS {
out.push_str(&format!(
"\n\nstopped at the {READ_CHARS}-character cap; {left} more definition{} of this name. Ask for Type::name to read one.",
if left == 1 { "" } else { "s" }
));
break;
}
if !out.is_empty() {
out.push_str("\n\n");
}
out.push_str(&text);
left -= 1;
}
format!(
"{} definitions of this name, each whole:\n{out}",
rows.len()
)
}
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.qualified,
row.kind,
label_of(&row.store),
paths.short(&row.path),
row.start_line,
row.end_line
));
}
out.trim_end().to_string()
}
Ok(Some(crate::Read::One(span))) => {
let text = one(&span);
if text.len() > READ_CHARS {
let cut: String = text.chars().take(READ_CHARS).collect();
let cut = cut
.rsplit_once('\n')
.map_or(cut.clone(), |(a, _)| a.to_string());
let shown = cut.lines().count().saturating_sub(1) as u32;
format!(
"{cut}\n\nstopped at the {READ_CHARS}-character cap, at line {}; ask for a line range to read on.",
span.start_line + shown.saturating_sub(1)
)
} else {
text
}
}
Err(e) => return Ok(tool_error(&e.to_string())),
};
paths.with_header(body)
}
"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(),
);
if let Ok(disk) = store.footprint(crate::compact::retention_in_force()) {
body.push_str(&format!(
", {} on disk ({} reclaimable)",
crate::human_bytes(disk.total() as i64),
crate::human_bytes(disk.reclaimable as i64),
));
}
match crate::store::coverage_by_language(store.db()) {
Ok(coverage) => {
const FEW: usize = 5;
let (tail_langs, tail_files) = coverage
.iter()
.filter(|r| r.definitions > 0 && r.files < FEW)
.fold((0usize, 0usize), |(l, f), r| (l + 1, f + r.files));
for row in coverage
.iter()
.filter(|r| r.definitions > 0 && r.files >= FEW)
{
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(),
));
}
if tail_langs > 0 {
body.push_str(&format!(
"\n + {tail_langs} more language{}, {tail_files} files",
if tail_langs == 1 { "" } else { "s" }
));
}
}
Err(e) => return Ok(tool_error(&format!("stats failed: {e}"))),
}
let variants = store.variants();
if !variants.is_empty() {
let parts: Vec<String> =
variants.iter().map(|(v, n)| format!("{n} {v}")).collect();
body.push_str(&format!("\n variants: {}", parts.join(", ")));
}
lines.push(if many {
format!("{label}: {body}")
} else {
body
});
}
for (store, remote) in crate::cloud::remote_stores() {
lines.push(format!(
"{store}: {}, answered by {}",
crate::cloud::badge(&remote),
crate::cloud::host_name(&remote.host)
));
}
let lanes = crate::accel::enabled().named();
lines.push(format!("embedding lanes on: {}", lanes.join(", ")));
lines.push(format!("vector cache: {}", crate::cache::stats().line()));
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.min(MAX_FILE_LIMIT) as usize)
.unwrap_or(FILE_LIMIT)
.max(1);
if args.get("tree").and_then(Value::as_bool).unwrap_or(false) {
let depth = args
.get("depth")
.and_then(Value::as_u64)
.unwrap_or(2)
.clamp(1, 8) as usize;
let sort = match crate::tree::Sort::parse(
args.get("sort").and_then(Value::as_str).unwrap_or(""),
) {
Ok(s) => s,
Err(e) => return Ok(tool_error(&e.to_string())),
};
match crate::tree::render(stores, Some(&only), &filter, depth, sort) {
Ok(text) => match stores.roots().as_slice() {
[one] => format!("root {}\n{text}", crate::plain(&one.to_string_lossy())),
_ => text,
},
Err(e) => return Ok(tool_error(&e.to_string())),
}
} else {
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 served: Vec<PathBuf> = stores
.each()
.flat_map(|(_, store)| crate::home::index_roots(store.dir()))
.collect();
if let Some(outside) = roots.iter().find(|r| !agent_may_reach(r, &served)) {
return Ok(tool_error(&format!(
"refused: {} is outside what an agent may index — the home directory \
and the roots of this machine's stores",
outside.display()
)));
}
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)));
}
let waiting = crate::store::refusals(store.db())
.map(|rows| {
rows.iter()
.filter(|r| r.reviewable && r.accepted.is_none())
.count()
})
.unwrap_or(0);
if waiting > 0 {
out.push_str(&format!(
"\n{waiting} file{} await{} the owner's review (semlith refused, or the portal's Files ▸ Not indexed).",
if waiting == 1 { "" } else { "s" },
if waiting == 1 { "s" } else { "" }
));
}
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 mut names = strings(&args, "names");
if let Some(name) = args.get("name").and_then(Value::as_str)
&& !names.iter().any(|n| n == name)
{
names.insert(0, name.to_string());
}
let Some(name) = names.first().cloned() else {
return Err((
-32602,
"missing required argument: name or names".into(),
None,
));
};
let name = name.as_str();
let k = args.get("k").and_then(Value::as_u64).unwrap_or(20) as usize;
let only = strings(&args, "store");
let filter = match filter_of(&args) {
Ok(f) => f,
Err(e) => return Ok(tool_error(&e)),
};
if names.len() > 1 {
if names.len() > crate::graph::NAMES_LIMIT {
return Ok(tool_error(&format!(
"at most {} names at once; {} given",
crate::graph::NAMES_LIMIT,
names.len()
)));
}
let paths = stores.shortener();
match stores.signatures_in(Some(&only), &names, &filter) {
Ok(rows) if rows.is_empty() => empty_graph(stores, &names.join(", ")),
Ok(rows) => {
let text = crate::graph::render_signatures(&rows, &|p| paths.short(p));
let missing: Vec<&String> = names
.iter()
.filter(|n| !rows.iter().any(|r| r.asked == **n))
.collect();
let text = if missing.is_empty() {
text
} else {
format!(
"{text}\nno definition: {}",
missing
.iter()
.map(|m| m.as_str())
.collect::<Vec<_>>()
.join(", ")
)
};
paths.with_header(crate::graph::fit(text, "ask for fewer names"))
}
Err(e) => return Ok(tool_error(&e.to_string())),
}
} else 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,
&filter,
) {
Ok(found) if found.definitions.is_empty() => empty_graph(stores, name),
Ok(found) => {
let paths = stores.shortener();
paths.with_header(crate::graph::fit(
found.render("", "", &|p| paths.short(p)),
"ask with a qualified name such as Type::method, or a smaller k",
))
}
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 filter = match filter_of(&args) {
Ok(f) => f,
Err(e) => return Ok(tool_error(&e)),
};
let all = args.get("all").and_then(Value::as_bool).unwrap_or(false);
match stores.neighbours_in(Some(&only), name, &kinds, all, &filter) {
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 paths = stores.shortener();
let mut body = format!(
"callers of {name} ({}):\n{}\n\ncallees of {name} ({}):\n{}",
n.callers.len(),
render_ends(&n.callers, &|p| paths.short(p)),
n.callees.len(),
render_ends(&n.callees, &|p| paths.short(p)),
);
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}"));
}
paths.with_header(crate::graph::fit(
body,
"ask with a qualified name such as Type::method, or a kind",
))
}
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(crate::report::DEFAULT_MODEL);
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 filter = match filter_of(&args) {
Ok(f) => f,
Err(e) => return Ok(tool_error(&e)),
};
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, &filter) {
Ok(impact) => {
let paths = stores.shortener();
paths.with_header(impact.render("", "", &|p| paths.short(p)))
}
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)),
};
if !remote.is_some_and(|r| r.skip_local) {
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,
&strings(args, "store"),
elapsed,
);
}
"semlith_brief" => {
let Some(question) = args.get("question").and_then(Value::as_str) else {
return;
};
crate::ledger::reply(
stores,
&who,
"brief",
question,
body,
&strings(args, "store"),
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."
)
}
}
pub fn render_grep(
found: &crate::pattern::Matches,
offset: usize,
budget: usize,
shorten: &dyn Fn(&str) -> String,
) -> String {
let total = found.matches.len();
let mut out = format!(
"{}{total} matching lines in {} files searched",
if found.truncated { "at least " } else { "" },
found.files
);
let mut shown = 0;
let mut rest: Vec<(String, usize)> = Vec::new();
let mut last = None;
for m in &found.matches {
let file = (&m.store, &m.path);
let name = format!("{}{}", label_of(&m.store), shorten(&m.path));
if !rest.is_empty() || out.len() > budget {
match rest.last_mut() {
Some((n, count)) if *n == name => *count += 1,
_ => rest.push((name, 1)),
}
continue;
}
if last != Some(file) {
out.push_str(&format!("\n{name}"));
last = Some(file);
}
let def = if m.capture.is_empty() {
String::new()
} else {
format!(" {}", m.capture)
};
out.push_str(&format!("\n {}{def} | {}", m.start_line, m.text));
shown += 1;
}
if !rest.is_empty() {
out.push_str(&format!("\n{} more lines, past the budget:", total - shown));
for (name, count) in &rest {
out.push_str(&format!("\n {name} {count}"));
}
}
if shown < total || found.truncated {
out.push_str(&format!(
"\nnarrow with path, or call again with offset: {} for the rest",
offset + shown
));
}
out
}
fn label_of(store: &Option<String>) -> String {
match store {
Some(label) => format!("[{label}] "),
None => String::new(),
}
}
fn render_ends(ends: &[crate::store::EdgeEnd], shorten: &dyn Fn(&str) -> String) -> 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, shorten)
);
}
format!(
" {} via {} ({}) {}{}:{}{}",
e.symbol.name,
e.kind,
e.confidence,
label_of(&e.symbol.store),
shorten(&e.symbol.path),
e.symbol.start_line,
crate::graph::call_site(e, shorten)
)
})
.collect::<Vec<_>>()
.join("\n")
}
pub 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 max_tokens(args: &Value) -> usize {
args.get("max_tokens")
.and_then(Value::as_u64)
.map_or(DEFAULT_LOCATE_TOKENS as u64, |v| v.clamp(200, 200_000)) as usize
}
const MAX_FILE_LIMIT: u64 = 10_000;
fn agent_may_reach(path: &std::path::Path, served: &[std::path::PathBuf]) -> bool {
use std::path::{Component, PathBuf};
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
match std::env::current_dir() {
Ok(cwd) => cwd.join(path),
Err(_) => return false,
}
};
let mut plain = PathBuf::new();
for part in absolute.components() {
match part {
Component::ParentDir => {
plain.pop();
}
Component::CurDir => {}
other => plain.push(other),
}
}
let unaliased = |p: &std::path::Path| -> PathBuf {
p.strip_prefix("/private")
.map(|rest| std::path::Path::new("/").join(rest))
.unwrap_or_else(|_| p.to_path_buf())
};
let mut allowed: Vec<PathBuf> = Vec::new();
if let Ok(home) = crate::home::user_home() {
allowed.push(crate::canonical(&home));
allowed.push(home);
}
if let Ok(registry) = crate::home::Registry::load() {
allowed.extend(registry.stores.into_values().flat_map(|e| e.roots));
}
allowed.extend(served.iter().cloned());
let plain = unaliased(&plain);
allowed.iter().any(|root| {
plain.starts_with(unaliased(&crate::canonical(root))) || plain.starts_with(unaliased(root))
})
}
fn tool_error(message: &str) -> Value {
json!({ "content": [{ "type": "text", "text": message }], "isError": true })
}
fn locate(
hits: &[crate::Hit],
query: &str,
max_tokens: usize,
shorten: &dyn Fn(&str) -> String,
) -> String {
locate_cut(hits, query, max_tokens, shorten).0
}
pub struct SearchReply {
pub text: String,
pub kept: Vec<bool>,
pub lines: Vec<String>,
pub tokens: usize,
}
pub fn search_reply(
fleet: &Fleet,
hits: &[crate::Hit],
query: &str,
prefer: crate::Prefer,
max_tokens: usize,
) -> SearchReply {
let paths = fleet.shortener();
let (rows, kept) = locate_cut(hits, query, max_tokens, &|p| paths.short(p));
let note = fleet
.pending_note()
.map(|n| format!("\n{n}"))
.unwrap_or_default();
let text = paths.with_header(format!("{}\n{rows}{note}", reading(query, prefer)));
let terms = query_terms(query);
let lines = hits
.iter()
.map(|hit| match hit.image {
Some(px) => format!("image {}x{} px", px.width, px.height),
None => best_line(&hit.text, &terms),
})
.collect();
SearchReply {
tokens: tokens(&text),
text,
kept,
lines,
}
}
pub fn brief_reply(fleet: &Fleet, brief: &crate::brief::Brief, filter: &crate::Filter) -> String {
if brief.spans.is_empty() {
return fleet.no_match_reason(filter);
}
let paths = fleet.shortener();
paths.with_header(render_brief(brief, &|p| paths.short(p)))
}
pub(crate) fn line_for(text: &str, query: &str) -> String {
best_line(text, &query_terms(query))
}
fn query_terms(query: &str) -> Vec<String> {
query
.split(|c: char| !c.is_alphanumeric() && c != '_')
.filter(|t| t.len() > 2)
.map(|t| t.to_lowercase())
.collect()
}
fn locate_cut(
hits: &[crate::Hit],
query: &str,
max_tokens: usize,
shorten: &dyn Fn(&str) -> String,
) -> (String, Vec<bool>) {
if hits.is_empty() {
return (String::new(), Vec::new());
}
let budget = max_tokens.max(MIN_LOCATE_TOKENS);
let terms = query_terms(query);
let mut files: Vec<(String, Vec<(usize, &crate::Hit)>)> = Vec::new();
for (at, hit) in hits.iter().enumerate() {
let label = match &hit.store {
Some(store) => format!("{store} {}", shorten(&hit.path)),
None => shorten(&hit.path),
};
match files.iter_mut().find(|(name, _)| *name == label) {
Some((_, group)) => group.push((at, hit)),
None => files.push((label, vec![(at, hit)])),
}
}
let mut blocks: Vec<(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, shorten));
}
blocks.push((group.iter().map(|(at, _)| *at).collect(), block));
}
let total = hits.len();
let mut out = String::new();
let mut kept = vec![false; total];
let mut shown = 0usize;
for (members, block) in blocks {
if !out.is_empty() && tokens(&out) + tokens(&block) > budget {
break;
}
out.push_str(&block);
shown += members.len();
for at in members {
kept[at] = true;
}
}
if shown < total {
out.push_str(&format!("truncated: {shown} of {total}\n"));
}
(out.trim_end().to_string(), kept)
}
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], shorten: &dyn Fn(&str) -> 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() {
marks.push(hit.lists.join("+"));
}
if !hit.fresh {
marks.push("stale".to_string());
}
if !hit.copies.is_empty() {
let copies: Vec<String> = hit.copies.iter().map(|p| shorten(p)).collect();
marks.push(format!(
"also in {} cop{}: {}",
copies.len(),
if copies.len() == 1 { "y" } else { "ies" },
copies.join(", ")
));
}
let at = match hit.symbol_line {
Some(line) => format!(" @{line}"),
None => String::new(),
};
let named = match (&hit.symbol, &hit.symbol_kind) {
(Some(name), Some(kind)) => format!(" {name} {kind}{at}"),
(Some(name), None) => format!(" {name}{at}"),
_ => String::new(),
};
let marked = if marks.is_empty() {
String::new()
} else {
format!(" \u{00b7} {}", marks.join(" \u{00b7} "))
};
format!(
" {}-{}{named}{marked} | {}\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 READ_CHARS: usize = 32_000;
const WHOLE_FILE_CHARS: usize = 8_000;
const READ_WHOLE: usize = 4;
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, shorten: &dyn Fn(&str) -> String) -> 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, span.symbol_line) {
(Some(name), Some(line)) => format!(" {name} @{line}"),
(Some(name), None) => format!(" {name}"),
_ => String::new(),
};
out.push_str(&format!(
"{}{}:{}-{}{what}{via}\n",
label_of(&span.store),
shorten(&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 if span.over_budget => out.push_str(" (text left out for the budget)\n"),
None => out.push_str(" (text: top span only)\n"),
}
}
for symbol in &brief.symbols {
out.push_str(&format!(
"{}{} [graph]\n",
label_of(&symbol.store),
symbol.name
));
for edge in &symbol.callers {
out.push_str(&format!(
" called by {} {}:{}\n",
edge.symbol.name,
shorten(&edge.symbol.path),
edge.symbol.start_line
));
}
for edge in &symbol.callees {
out.push_str(&format!(
" calls {} {}:{}\n",
edge.symbol.name,
shorten(&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
}
pub(crate) 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 })
}
}
}
pub const TYPICAL_ESTIMATES: &[(&str, &str, i64)] = &[
(
"semlith_search",
"where something is: one line per hit",
700,
),
(
"semlith_brief",
"how something works: spans, text and callers",
1800,
),
("semlith_read", "one span or definition, whole", 900),
(
"semlith_pattern",
"every place one syntax shape occurs",
600,
),
("semlith_stats", "what is indexed, per store", 150),
(
"semlith_languages",
"which languages filter and carry a graph",
300,
),
("semlith_files", "what a folder holds", 450),
(
"semlith_index",
"a folder indexed, and what was skipped",
120,
),
("semlith_add", "one URL fetched and indexed", 80),
("semlith_forget", "one file taken out", 40),
(
"semlith_symbol",
"where a name is defined and what touches it",
300,
),
("semlith_neighbors", "who calls it and what it calls", 350),
("semlith_report", "one of the five reports", 1200),
("semlith_impact", "every caller a change would reach", 450),
(
"semlith_trace",
"the chain from A to B, a line per hop",
400,
),
("semlith_path", "whether A reaches B, and how", 200),
];
pub const TYPICAL_MIN_ROWS: usize = 5;
pub fn tool_catalog(seen: &mut std::collections::BTreeMap<String, Vec<i64>>) -> Vec<Value> {
let listed = listed_names();
tool_list()
.into_iter()
.map(|(name, about)| {
let short = name.strip_prefix("semlith_").unwrap_or(&name).to_string();
let offered = listed.contains(&name);
let (answers, estimate) = TYPICAL_ESTIMATES
.iter()
.find(|(n, _, _)| *n == name)
.map(|(_, answers, tokens)| (answers.to_string(), *tokens))
.unwrap_or_else(|| (about.clone(), 300));
let (typical, source) = match seen.get_mut(&short) {
Some(rows) if rows.len() >= TYPICAL_MIN_ROWS => {
rows.sort_unstable();
(rows[rows.len() / 2], "ledger")
}
_ => (estimate, "estimate"),
};
json!({
"name": name,
"about": about,
"answers": answers,
"typical_tokens": typical,
"typical_source": source,
"listed": offered,
})
})
.collect()
}
pub fn tool_list_tokens(counter: &crate::ledger::Counter) -> i64 {
let listed = listed_names();
let text = tool_list()
.into_iter()
.filter(|(name, _)| listed.contains(name))
.map(|(name, about)| format!("{name} {about}"))
.collect::<Vec<_>>()
.join(" ");
let count = counter.count(&text);
let bytes = tool_list_bytes() as i64;
let prose = text.len().max(1) as i64;
count * bytes / prose
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn initialize_labels_by_the_host_it_was_handed_not_the_serving_process() {
let hello = json!({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": { "clientInfo": { "name": "flow-test", "version": "1" } }
});
let mut fleet = Fleet::empty();
let mut session = Session::new("s");
answer(&mut fleet, None, &hello, &mut session).expect("an answer");
assert_eq!(
session.client,
crate::clientid::label(Some("flow-test"), None)
);
let mut proxied = Session::new("p");
proxied.host = Some("Claude Desktop".to_string());
answer(&mut fleet, None, &hello, &mut proxied).expect("an answer");
assert_eq!(
proxied.client,
crate::clientid::label(Some("flow-test"), Some("Claude Desktop"))
);
}
#[test]
fn the_instructions_stay_within_their_limit_for_any_fleet() {
for count in 0..=40 {
for width in [8, 40, 75, 120, 300, 700] {
let roots: Vec<std::path::PathBuf> = (0..count)
.map(|i| std::path::PathBuf::from(format!("/{i}{}", "x".repeat(width))))
.collect();
let said = instructions(&roots);
assert!(
said.len() <= INSTRUCTIONS_LIMIT,
"{count} roots of {width} bytes: {} bytes: {said}",
said.len()
);
}
}
}
#[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()),
symbol_line: None,
copies: Vec::new(),
}
}
#[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, &crate::plain);
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, &crate::plain);
assert!(!full.contains("truncated:"), "nothing was cut: {full}");
let cut = locate(&hits, "helper", MIN_LOCATE_TOKENS, &crate::plain);
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}"
);
let (text, kept) = locate_cut(&hits, "helper", MIN_LOCATE_TOKENS, &crate::plain);
assert_eq!(text, cut);
let shown = kept.iter().filter(|k| **k).count();
assert!(cut.contains(&format!("truncated: {shown} of 40")), "{cut}");
for (hit, kept) in hits.iter().zip(&kept) {
assert_eq!(
cut.contains(&format!("{}\n", hit.path)),
*kept,
"{}",
hit.path
);
}
}
#[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, &crate::plain);
assert!(reply.contains("src/store.rs"), "{reply}");
}
#[test]
fn a_stale_hit_is_marked() {
let mut stale = hit("src/lib.rs", 10, 20, "fn a() {}\n");
stale.fresh = false;
let fresh = hit("src/graph.rs", 30, 40, "fn b() {}\n");
let reply = locate(&[stale, fresh], "a", DEFAULT_LOCATE_TOKENS, &crate::plain);
assert_eq!(reply.matches("stale").count(), 1, "{reply}");
}
#[test]
fn an_exact_search_past_its_budget_lists_the_rest_as_counts() {
let row = |path: &str, line: u32| crate::pattern::Match {
path: path.to_string(),
capture: "f".to_string(),
start_line: line,
end_line: line,
text: "x".repeat(60),
store: None,
};
let mut matches: Vec<_> = (1..=30).map(|l| row("a.rs", l)).collect();
matches.extend((1..=5).map(|l| row("b.rs", l)));
let found = crate::pattern::Matches {
language: String::new(),
matches,
files: 2,
truncated: false,
skipped: 0,
};
let all = render_grep(&found, 0, usize::MAX, &|p| p.to_string());
assert_eq!(all.lines().filter(|l| l.contains(" f | ")).count(), 35);
let cut = render_grep(&found, 0, 800, &|p| p.to_string());
assert!(cut.len() < 1200, "{} chars: {cut}", cut.len());
assert!(cut.starts_with("35 matching lines"), "{cut}");
assert!(cut.contains("\n b.rs 5"), "{cut}");
let shown = cut.lines().filter(|l| l.contains(" f | ")).count();
assert!(cut.contains(&format!("offset: {shown} ")), "{cut}");
}
#[test]
fn every_array_parameter_says_what_it_holds() {
fn walk(at: &str, schema: &Value, bad: &mut Vec<String>) {
if schema["type"] == "array" && schema.get("items").is_none() {
bad.push(at.to_string());
}
if let Some(props) = schema["properties"].as_object() {
for (name, sub) in props {
walk(&format!("{at}.{name}"), sub, bad);
}
}
if let Some(items) = schema.get("items") {
walk(&format!("{at}[]"), items, bad);
}
}
let mut bad = Vec::new();
for tool in tool_defs("default").as_array().unwrap() {
walk(
tool["name"].as_str().unwrap(),
&tool["inputSchema"],
&mut bad,
);
}
assert!(bad.is_empty(), "arrays without items: {bad:?}");
}
#[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 <= 2_700,
"tools/list is {size} bytes, over the 2 700 the 675-token gate allows: {}",
each.join(" ")
);
}
}