use cctop_core::loader::Loader;
use cctop_core::pricing::Plan;
use cctop_core::session::Session;
use cctop_core::session::lookup::{Lookup, lookup};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Deserializer};
use serde_json::{Value, json};
use std::io::{BufRead, Write};
const PROTOCOL_VERSIONS: [&str; 3] = ["2025-06-18", "2025-03-26", "2024-11-05"];
const DEFAULT_LIMIT: usize = 25;
const MAX_LIMIT: u64 = 200;
fn limit_of(limit: Option<u64>) -> usize {
match limit {
Some(n) => n.clamp(1, MAX_LIMIT) as usize,
None => DEFAULT_LIMIT,
}
}
const MAX_WAIT: std::time::Duration = std::time::Duration::from_secs(300);
const DEFAULT_WAIT: std::time::Duration = std::time::Duration::from_secs(60);
pub fn serve() -> anyhow::Result<()> {
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
for line in stdin.lock().lines() {
let Some(response) = answer_line(&line?) else {
continue;
};
writeln!(stdout, "{response}")?;
stdout.flush()?;
}
Ok(())
}
fn answer_line(line: &str) -> Option<Value> {
if line.trim().is_empty() {
return None;
}
let request: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(e) => {
return Some(error_reply(
Value::Null,
RpcError::new(PARSE_ERROR, format!("parse error: {e}")),
));
}
};
match request {
Value::Array(batch) if batch.is_empty() => Some(error_reply(
Value::Null,
RpcError::new(INVALID_REQUEST, "an empty batch"),
)),
Value::Array(batch) => {
let replies: Vec<Value> = batch.iter().filter_map(handle).collect();
(!replies.is_empty()).then_some(Value::Array(replies))
}
request => handle(&request),
}
}
const PARSE_ERROR: i64 = -32700;
const INVALID_REQUEST: i64 = -32600;
const METHOD_NOT_FOUND: i64 = -32601;
const INVALID_PARAMS: i64 = -32602;
#[derive(Debug)]
struct RpcError {
code: i64,
message: String,
}
impl RpcError {
fn new(code: i64, message: impl Into<String>) -> RpcError {
RpcError {
code,
message: message.into(),
}
}
}
fn error_reply(id: Value, error: RpcError) -> Value {
json!({
"jsonrpc": "2.0",
"id": id,
"error": {"code": error.code, "message": error.message},
})
}
pub fn install(scope: &str) -> Vec<String> {
let exe = match std::env::current_exe()
.ok()
.and_then(|p| p.to_str().map(str::to_string))
{
Some(exe) => exe,
None => return vec!["could not find cctop's own path".into()],
};
let mut out = Vec::new();
let mut run = |agent: &str, args: Vec<&str>| {
let shown = format!("{agent} {}", args.join(" "));
match std::process::Command::new(agent).args(&args).output() {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
out.push(format!("{agent}: not installed, skipped"));
}
Err(e) => out.push(format!("{agent}: could not run `{shown}`: {e}")),
Ok(o) if o.status.success() => out.push(format!("{agent}: registered (`{shown}`)")),
Ok(o) => {
let said = String::from_utf8_lossy(&o.stderr).to_string()
+ &String::from_utf8_lossy(&o.stdout);
let said = said.trim();
if said.to_lowercase().contains("already exists") {
out.push(format!(
"{agent}: a server named cctop is already registered; \
`{agent} mcp get cctop` shows where it points"
));
} else {
out.push(format!("{agent}: `{shown}` failed: {said}"));
}
}
}
};
match scope {
"project" => {
run(
"claude",
vec![
"mcp", "add", "--scope", "project", "cctop", "--", &exe, "--mcp",
],
);
out.push("codex: has no per-project MCP servers; run --install-mcp user for it".into());
}
_ => {
run(
"claude",
vec![
"mcp", "add", "--scope", "user", "cctop", "--", &exe, "--mcp",
],
);
run("codex", vec!["mcp", "add", "cctop", "--", &exe, "--mcp"]);
}
}
out.push(format!(
"Anything else that speaks MCP: {}",
json!({"mcpServers": {"cctop": {"command": exe, "args": ["--mcp"]}}})
));
out
}
fn handle(request: &Value) -> Option<Value> {
let id = request.get("id")?.clone();
let Some(method) = request.get("method").and_then(Value::as_str) else {
return Some(error_reply(
id,
RpcError::new(INVALID_REQUEST, "a request needs a method"),
));
};
let params = request.get("params");
let result = match method {
"initialize" => Ok(json!({
"protocolVersion": negotiate(params),
"capabilities": {"tools": {}},
"serverInfo": {"name": "cctop", "version": env!("CARGO_PKG_VERSION")},
"instructions": INSTRUCTIONS,
})),
"tools/list" => Ok(json!({"tools": tool_schemas()})),
"tools/call" => call_tool(params),
"ping" => Ok(json!({})),
other => Err(RpcError::new(
METHOD_NOT_FOUND,
format!("unknown method '{other}'"),
)),
};
Some(match result {
Ok(result) => json!({"jsonrpc": "2.0", "id": id, "result": result}),
Err(error) => error_reply(id, error),
})
}
fn negotiate(params: Option<&Value>) -> &'static str {
let asked = params
.and_then(|p| p.get("protocolVersion"))
.and_then(Value::as_str);
PROTOCOL_VERSIONS
.iter()
.find(|v| Some(**v) == asked)
.unwrap_or(&PROTOCOL_VERSIONS[0])
}
const INSTRUCTIONS: &str = "cctop reads every coding-agent session on this machine — \
Claude Code, Codex, OpenCode and others, past and running. Before re-deciding \
something, re-investigating a bug, or asking the user what was settled \
earlier, call `recall` with the question: it returns the passages of past \
sessions, from any agent, that discussed it, and `read_passage` opens one in \
context. Cite what you use by its session id. Use `check_conflicts` before \
editing files another running agent may also be editing.";
fn tool_schemas() -> Vec<Value> {
vec![
json!({
"name": "list_sessions",
"description": "List AI coding agent sessions on this machine — every harness, \
not just your own. Returns harness, model, working directory, \
git branch, token usage, estimated cost, context window \
occupancy, and whether the session is still running. Use this to \
find out what other agents are working on before you start.",
"inputSchema": {
"type": "object",
"properties": {
"running_only": {
"type": "boolean",
"description": "Only sessions with a live process behind them.",
},
"directory": {
"type": "string",
"description": "Only sessions whose working directory is at or under \
this path. Use it to ask who else is in this repo.",
},
"limit": {
"type": "integer",
"description":
"Maximum sessions to return, most recently active first. \
Defaults to 25, and is capped at 200 however large a \
number is passed.",
},
},
},
}),
json!({
"name": "get_session_context",
"description": "Get a context brief for one session: what it was doing, the plan \
it was working to, the files it changed and read, the commands it \
ran, and what it delegated. This is the handoff document — read it \
to continue another agent's work. Returns markdown.",
"inputSchema": {
"type": "object",
"properties": {
"session_id": {
"type": "string",
"description": "Session id, or any unique prefix of one, as returned \
by list_sessions.",
},
},
"required": ["session_id"],
},
}),
json!({
"name": "check_conflicts",
"description": "Ask whether another agent is already working where you are about \
to. Give it your working directory and, if you know them, the files \
you intend to change; it answers with the running sessions in the \
same repository and which of your files they have already written. \
Two agents writing one file is not a merge conflict — nothing warns \
you and one edit is simply lost — so check before a batch of edits, \
and take a worktree if someone is there.",
"inputSchema": {
"type": "object",
"properties": {
"directory": {
"type": "string",
"description": "Your working directory. The repository containing it is \
what gets compared, so a subdirectory is fine.",
},
"files": {
"type": "array",
"items": {"type": "string"},
"description": "Paths you are about to change, absolute or relative to \
`directory`. Optional: without them the answer is just \
who else is in the repository.",
},
},
"required": ["directory"],
},
}),
json!({
"name": "wait_for_session",
"description": "Block until another agent's session stops working, then say what \
it is doing. Use it to wait for an agent you handed work to, or \
one you sent a prompt to, before reading its result. Returns JSON: \
state (working, idle, waiting, error, ended), met, timed_out, \
waited_secs. On a timeout, call it again.",
"inputSchema": {
"type": "object",
"properties": {
"session": {
"type": "string",
"description": "Session id or a unique prefix of one (as returned by \
list_sessions), a cctop tab name, or a pid.",
},
"until": {
"type": "string",
"enum": ["idle", "waiting", "done", "any-stop"],
"description": "idle: its turn is over. waiting: it is blocked on a \
question. done: it worked since this call began and \
has stopped since. any-stop (default): it is not \
working, now or once it stops.",
},
"timeout_secs": {
"type": "integer",
"description": "Give up after this many seconds. Defaults to 60; \
at most 300.",
},
},
"required": ["session"],
},
}),
json!({
"name": "recall",
"description": "Recall what past coding sessions on this machine said about \
something — decisions and their reasons, bugs and their fixes, \
findings, plans — from any agent (Claude Code, Codex, OpenCode \
and others). Returns the best-matching passages, ranked by \
their words and by their meaning, each with its session id, \
agent, directory and date. Use it before re-deriving something \
that may already have been worked out. This session is left out.",
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "A question or a few keywords, e.g. 'why is the cache \
sharded' or 'vast.ai nemotron'.",
},
"limit": {
"type": "integer",
"description": "Passages to return. Defaults to 8.",
},
},
"required": ["query"],
},
}),
json!({
"name": "read_passage",
"description": "Open one passage that `recall` returned, with the passages either \
side of it, to read a hit in context.",
"inputSchema": {
"type": "object",
"properties": {
"session": {
"type": "string",
"description": "The session id from a recall result, or a unique prefix.",
},
"passage": {
"type": "integer",
"description": "The passage number from the recall result.",
},
"around": {
"type": "integer",
"description": "Neighbouring passages to include either side. Defaults to 1.",
},
},
"required": ["session", "passage"],
},
}),
json!({
"name": "search_sessions",
"description": "Search the full text of every session transcript on this machine \
for a string, and return the sessions that mention it with a \
snippet of the match. Use it to find where something was already \
discussed or attempted, in any harness.",
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Text to look for. Case-insensitive, matched literally.",
},
"limit": {
"type": "integer",
"description": "Maximum matching sessions to return. Defaults to 25, \
and is capped at 200 however large a number is passed.",
},
},
"required": ["query"],
},
}),
]
}
const TOOLS: [&str; 7] = [
"list_sessions",
"get_session_context",
"check_conflicts",
"wait_for_session",
"recall",
"read_passage",
"search_sessions",
];
fn call_tool(params: Option<&Value>) -> Result<Value, RpcError> {
let name = params
.and_then(|p| p.get("name"))
.and_then(Value::as_str)
.ok_or_else(|| RpcError::new(INVALID_PARAMS, "tools/call needs a tool name"))?;
if !TOOLS.contains(&name) {
return Err(RpcError::new(
INVALID_PARAMS,
format!("unknown tool '{name}'"),
));
}
let args = params
.and_then(|p| p.get("arguments"))
.cloned()
.unwrap_or_else(|| json!({}));
let (text, is_error) = match run_tool(name, args) {
Ok(text) => (text, false),
Err(text) => (text, true),
};
Ok(json!({"content": [{"type": "text", "text": text}], "isError": is_error}))
}
fn run_tool(name: &str, args: Value) -> Result<String, String> {
if name == "wait_for_session" {
return wait_for_session(parse(name, args)?);
}
let mut loader = Loader::new();
let sessions = loader.load(Plan::Retail);
let text = match name {
"list_sessions" => list_sessions(&sessions, parse(name, args)?),
"get_session_context" => get_session_context(&sessions, &loader, parse(name, args)?)?,
"check_conflicts" => check_conflicts(&sessions, parse(name, args)?),
"search_sessions" => search_sessions(&sessions, parse(name, args)?)?,
"recall" => recall(&sessions, parse(name, args)?)?,
"read_passage" => read_passage(&sessions, parse(name, args)?)?,
other => unreachable!("{other} is not in TOOLS"),
};
loader.store().save();
Ok(text)
}
fn parse<T: DeserializeOwned>(tool: &str, args: Value) -> Result<T, String> {
serde_json::from_value(args).map_err(|e| format!("{tool}: {e}"))
}
fn lenient_u64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<u64>, D::Error> {
Ok(Value::deserialize(d)?.as_u64())
}
#[derive(Deserialize)]
struct ListArgs {
#[serde(default)]
running_only: bool,
directory: Option<String>,
#[serde(default, deserialize_with = "lenient_u64")]
limit: Option<u64>,
}
#[derive(Deserialize)]
struct ContextArgs {
session_id: String,
}
#[derive(Deserialize)]
struct ConflictArgs {
directory: String,
#[serde(default)]
files: Vec<String>,
}
#[derive(Deserialize)]
struct WaitArgs {
session: String,
until: Option<String>,
#[serde(default, deserialize_with = "lenient_u64")]
timeout_secs: Option<u64>,
}
#[derive(Deserialize)]
struct RecallArgs {
query: String,
#[serde(default, deserialize_with = "lenient_u64")]
limit: Option<u64>,
}
#[derive(Deserialize)]
struct PassageArgs {
session: String,
passage: u64,
#[serde(default, deserialize_with = "lenient_u64")]
around: Option<u64>,
}
#[derive(Deserialize)]
struct SearchArgs {
query: String,
#[serde(default, deserialize_with = "lenient_u64")]
limit: Option<u64>,
}
fn wait_for_session(args: WaitArgs) -> Result<String, String> {
let target = args.session.as_str();
let until = match args.until.as_deref() {
None => crate::wait::Until::AnyStop,
Some(word) => crate::wait::Until::parse(word).ok_or_else(|| {
format!("until must be idle, waiting, done or any-stop, not '{word}'")
})?,
};
let timeout = args
.timeout_secs
.map_or(DEFAULT_WAIT, std::time::Duration::from_secs)
.min(MAX_WAIT);
match crate::wait::wait(target, until, Some(timeout), false) {
Ok(outcome) => Ok(outcome.json().to_string()),
Err(crate::wait::Unresolved::Unknown) => Err(format!(
"no session, tab or pid here is '{target}'; list_sessions shows them"
)),
Err(crate::wait::Unresolved::Ambiguous(ids)) => Err(format!(
"'{target}' matches {} sessions; give more of the id: {}",
ids.len(),
ids.join(", ")
)),
}
}
fn list_sessions(sessions: &[Session], args: ListArgs) -> String {
let running_only = args.running_only;
let directory = args.directory.as_deref();
let limit = limit_of(args.limit);
let mut matched: Vec<&Session> = sessions
.iter()
.filter(|s| !running_only || s.is_running())
.filter(|s| match directory {
Some(dir) => s.label_source.starts_with(dir.trim_end_matches('/')),
None => true,
})
.collect();
matched.sort_by(|a, b| b.last_active.cmp(&a.last_active));
let total = matched.len();
matched.truncate(limit);
let rows: Vec<Value> = matched
.iter()
.map(|s| {
json!({
"session_id": s.session_id,
"harness": s.harness,
"provider": s.provider.as_str(),
"model": s.model,
"title": s.title,
"user": s.owner,
"directory": s.label_source,
"branch": cctop_core::branch::branch_of(s),
"branch_note": "the branch checked out now, not necessarily the one it worked on",
"running": s.is_running(),
"started_at": s.started_at,
"last_active": s.last_active,
"input_tokens": s.input_tokens,
"output_tokens": s.output_tokens,
"estimated_cost_usd": s.total_cost,
"context_used": s.context.map(|c| c.used),
"context_max": s.context.map(|c| c.max),
})
})
.collect();
let payload = json!({
"sessions": rows,
"returned": rows.len(),
"total_matching": total,
"truncated": total > rows.len(),
"cost_note": "Costs are estimates from published per-token rates. Flat-rate plans \
bill differently.",
});
serde_json::to_string_pretty(&payload).unwrap_or_default()
}
fn check_conflicts(sessions: &[Session], args: ConflictArgs) -> String {
let directory = args.directory.as_str();
let peers = cctop_core::collide::peers_of(sessions, directory, &args.files);
let contested: Vec<&str> = peers
.iter()
.filter(|(_, shared)| !shared.is_empty())
.flat_map(|(_, shared)| shared.iter().map(String::as_str))
.collect();
let rows: Vec<Value> = peers
.iter()
.map(|(s, shared)| {
json!({
"session_id": s.session_id,
"harness": s.harness,
"directory": s.label_source,
"last_active": s.last_active,
"your_files_it_has_written": shared,
"recently_written": s.recent_writes,
})
})
.collect();
let payload = json!({
"directory": directory,
"agents_here": rows,
"contested_files": contested,
"clear": contested.is_empty(),
"own_row_note": "One of these is probably you — an agent calling this has a session of \
its own. Match on session_id or working directory.",
"advice": match contested.is_empty() {
true if rows.len() > 1 =>
"Another agent is in this repository but has not written your files. Nothing is \
lost yet; check again if you start editing widely.",
true => "Nobody else is running here.",
false =>
"Another running agent has already written these files. Whichever of you saves \
last wins and the other edit is gone, with no warning from git. Take a worktree, \
or agree who finishes first.",
},
"limits": "Only running sessions are compared, only the files each has written recently, \
and a linked git worktree counts as a separate repository — which is the point \
of one. A file is stronger evidence than a shared repository, though: a peer \
that already holds one of your files is reported wherever it was launched.",
});
serde_json::to_string_pretty(&payload).unwrap_or_default()
}
fn get_session_context(
sessions: &[Session],
loader: &Loader,
args: ContextArgs,
) -> Result<String, String> {
let wanted = args.session_id.as_str();
let session = match lookup(sessions, wanted) {
Lookup::Found(only) => only,
Lookup::None => return Err(format!("no session id starts with '{wanted}'")),
Lookup::Ambiguous(many) => {
return Err(format!(
"'{wanted}' matches {} sessions: {}",
many.len(),
many.iter()
.map(|s| s.session_id.as_str())
.collect::<Vec<_>>()
.join(", ")
));
}
};
let data = loader.store().session_data(session);
Ok(cctop_core::handoff::rendered(&cctop_core::handoff::build(
session,
Some(&data),
)))
}
fn recall(sessions: &[Session], args: RecallArgs) -> Result<String, String> {
let query = args.query.as_str();
if query.trim().is_empty() {
return Err("recall needs a query".into());
}
let limit = args
.limit
.map_or(crate::recall::DEFAULT_LIMIT, |n| n.clamp(1, 50) as usize);
let me = crate::recall::caller(sessions);
let answer = crate::recall::recall(sessions, query, limit, me.as_deref());
Ok(crate::recall::render(&answer))
}
fn read_passage(sessions: &[Session], args: PassageArgs) -> Result<String, String> {
let around = args.around.unwrap_or(1).min(5) as usize;
crate::recall::read(sessions, &args.session, args.passage as usize, around)
}
fn search_sessions(sessions: &[Session], args: SearchArgs) -> Result<String, String> {
let query = args.query.as_str();
if query.trim().is_empty() {
return Err("search_sessions needs a non-empty query".into());
}
let limit = limit_of(args.limit);
let needle = query.to_lowercase();
let mut ordered: Vec<&Session> = sessions.iter().collect();
ordered.sort_by(|a, b| b.last_active.cmp(&a.last_active));
let mut hits = Vec::new();
for session in ordered {
if hits.len() >= limit {
break;
}
let target = cctop_core::session::search::Target::of(session);
let Some(hit) = cctop_core::session::search::find(&target, &needle) else {
continue;
};
hits.push(json!({
"session_id": session.session_id,
"harness": session.harness,
"provider": session.provider.as_str(),
"directory": session.label_source,
"title": session.title,
"last_active": session.last_active,
"snippet": hit.snippet,
}));
}
let payload = json!({
"query": query,
"matches": hits,
"note": format!(
"Searched newest-first and stopped at {limit} matches; there may be older ones."
),
});
Ok(serde_json::to_string_pretty(&payload).unwrap_or_default())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn notifications_get_no_reply() {
let notification = json!({"jsonrpc": "2.0", "method": "notifications/initialized"});
assert!(handle(¬ification).is_none());
}
#[test]
fn initialize_answers_with_the_protocol_version() {
let request = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize"});
let response = handle(&request).expect("a reply");
assert_eq!(response["result"]["protocolVersion"], PROTOCOL_VERSIONS[0]);
assert_eq!(response["id"], 1);
}
#[test]
fn the_instructions_point_at_tools_that_exist() {
let request = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize"});
let response = handle(&request).expect("a reply");
let said = response["result"]["instructions"]
.as_str()
.expect("instructions");
let offered: Vec<String> = tool_schemas()
.iter()
.map(|t| t["name"].as_str().unwrap_or_default().to_string())
.collect();
for named in ["recall", "read_passage", "check_conflicts"] {
assert!(said.contains(named), "instructions do not mention {named}");
assert!(offered.iter().any(|t| t == named), "{named} is not a tool");
}
}
#[test]
fn every_tool_is_fully_described() {
for tool in tool_schemas() {
let name = tool["name"].as_str().expect("a name");
assert!(
tool["description"].as_str().is_some_and(|d| d.len() > 40),
"{name} needs a description an agent can choose from"
);
assert_eq!(tool["inputSchema"]["type"], "object", "{name}");
}
}
#[test]
fn an_unknown_method_still_gets_an_answer() {
let request = json!({"jsonrpc": "2.0", "id": 7, "method": "resources/list"});
let response = handle(&request).expect("a reply");
assert_eq!(response["id"], 7);
assert_eq!(response["error"]["code"], METHOD_NOT_FOUND);
assert!(response["error"]["message"].as_str().is_some());
}
#[test]
fn a_malformed_line_gets_a_parse_error_with_a_null_id() {
let reply = answer_line("{\"jsonrpc\": \"2.0\", \"id\": 3,").expect("a reply");
assert_eq!(reply["error"]["code"], PARSE_ERROR);
assert_eq!(reply["id"], Value::Null);
assert!(
answer_line(" ").is_none(),
"a blank line is not a message"
);
}
#[test]
fn a_batch_is_answered_as_one_array() {
let line = json!([
{"jsonrpc": "2.0", "id": 1, "method": "ping"},
{"jsonrpc": "2.0", "method": "notifications/initialized"},
{"jsonrpc": "2.0", "id": 2, "method": "nope"},
])
.to_string();
let reply = answer_line(&line).expect("a reply");
let replies = reply.as_array().expect("an array");
assert_eq!(replies.len(), 2);
assert_eq!(replies[0]["id"], 1);
assert_eq!(replies[1]["error"]["code"], METHOD_NOT_FOUND);
let quiet = json!([{"jsonrpc": "2.0", "method": "notifications/initialized"}]);
assert!(answer_line(&quiet.to_string()).is_none());
}
#[test]
fn initialize_negotiates_the_protocol_version() {
let init = |version: &str| {
let request = json!({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": version, "capabilities": {}},
});
handle(&request).expect("a reply")["result"]["protocolVersion"].clone()
};
for version in PROTOCOL_VERSIONS {
assert_eq!(init(version), version);
}
assert_eq!(init("1999-01-01"), PROTOCOL_VERSIONS[0]);
}
#[test]
fn a_tool_failure_is_a_result_and_an_unknown_tool_an_error() {
let call = |params: Value| {
let request =
json!({"jsonrpc": "2.0", "id": 9, "method": "tools/call", "params": params});
handle(&request).expect("a reply")
};
let failed = call(json!({"name": "get_session_context", "arguments": {}}));
assert!(failed.get("error").is_none(), "{failed}");
assert_eq!(failed["result"]["isError"], true);
let text = failed["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("session_id"), "{text}");
let unknown = call(json!({"name": "drop_tables"}));
assert_eq!(unknown["error"]["code"], INVALID_PARAMS);
let nameless = call(json!({}));
assert_eq!(nameless["error"]["code"], INVALID_PARAMS);
}
#[test]
fn the_dispatched_tools_are_the_listed_ones() {
let listed: Vec<String> = tool_schemas()
.iter()
.map(|t| t["name"].as_str().unwrap().to_string())
.collect();
assert_eq!(listed, TOOLS);
}
fn args<T: DeserializeOwned>(value: Value) -> T {
parse("test", value).expect("arguments that parse")
}
fn limit_of_json(value: Value) -> usize {
limit_of(args::<ListArgs>(value).limit)
}
#[test]
fn the_wait_tool_offers_exactly_the_conditions_it_takes() {
let tool = tool_schemas()
.into_iter()
.find(|tool| tool["name"] == "wait_for_session")
.expect("the tool is listed");
let words = tool["inputSchema"]["properties"]["until"]["enum"]
.as_array()
.expect("an enum")
.clone();
for word in &words {
let word = word.as_str().expect("a word");
assert!(crate::wait::Until::parse(word).is_some(), "{word}");
}
assert_eq!(words.len(), 4);
let bad = run_tool(
"wait_for_session",
json!({"session": "x", "until": "never"}),
);
assert!(bad.unwrap_err().contains("until must be"));
assert!(run_tool("wait_for_session", json!({})).is_err());
}
#[test]
fn a_limit_from_the_model_is_clamped_rather_than_obeyed() {
assert_eq!(
limit_of_json(json!({"limit": u64::MAX})),
MAX_LIMIT as usize
);
assert_eq!(
limit_of_json(json!({"limit": MAX_LIMIT * 1_000})),
MAX_LIMIT as usize
);
assert_eq!(limit_of_json(json!({"limit": 0})), 1);
assert_eq!(limit_of_json(json!({"limit": -1})), DEFAULT_LIMIT);
assert_eq!(limit_of_json(json!({"limit": i64::MIN})), DEFAULT_LIMIT);
assert_eq!(limit_of_json(json!({"limit": 1e30})), DEFAULT_LIMIT);
assert_eq!(
limit_of_json(json!({"limit": "18446744073709551615"})),
DEFAULT_LIMIT
);
assert_eq!(limit_of_json(json!({})), DEFAULT_LIMIT);
assert_eq!(limit_of_json(json!({"limit": 3})), 3);
assert_eq!(
limit_of_json(json!({"limit": MAX_LIMIT})),
MAX_LIMIT as usize
);
}
#[test]
fn the_advertised_limit_agrees_with_the_enforced_one() {
for name in ["list_sessions", "search_sessions"] {
let tool = tool_schemas()
.into_iter()
.find(|tool| tool["name"] == name)
.expect("the tool is listed");
let said = tool["inputSchema"]["properties"]["limit"]["description"]
.as_str()
.expect("a description");
assert!(
said.contains(&format!("{DEFAULT_LIMIT}")) && said.contains(&MAX_LIMIT.to_string()),
"{name} advertises {said:?}, which does not name {DEFAULT_LIMIT} and {MAX_LIMIT}"
);
}
}
fn many_sessions(n: usize) -> Vec<Session> {
(0..n)
.map(|i| {
let mut s = Session::new(cctop_core::pricing::Provider::Claude, format!("s{i:04}"));
s.label_source = "/tmp/proj".into();
s.last_active = format!("2026-09-28T00:00:{:02}.000Z", i % 60);
s
})
.collect()
}
#[test]
fn a_listing_stops_at_the_cap_however_large_a_limit_is() {
let sessions = many_sessions(MAX_LIMIT as usize + 10);
let text = list_sessions(&sessions, args(json!({"limit": u64::MAX})));
let out: Value = serde_json::from_str(&text).unwrap();
assert_eq!(out["returned"], MAX_LIMIT);
assert_eq!(out["truncated"], true);
assert_eq!(out["total_matching"], MAX_LIMIT as usize + 10);
let text = list_sessions(&sessions, args(json!({"limit": 0})));
let out: Value = serde_json::from_str(&text).unwrap();
assert_eq!(out["returned"], 1);
}
#[test]
fn a_search_stops_at_the_cap_however_large_a_limit_is() {
let dir = std::env::temp_dir().join(format!("cctop-mcp-limit-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("hit.jsonl");
std::fs::write(&file, "{\"text\":\"the needle is here\"}\n").unwrap();
let sessions: Vec<Session> = many_sessions(MAX_LIMIT as usize + 10)
.into_iter()
.map(|mut s| {
s.data_file = Some(file.clone());
s
})
.collect();
let text = search_sessions(
&sessions,
args(json!({"query": "needle", "limit": u64::MAX})),
)
.expect("an answer");
let out: Value = serde_json::from_str(&text).unwrap();
assert_eq!(out["matches"].as_array().unwrap().len(), MAX_LIMIT as usize);
assert!(
out["note"]
.as_str()
.unwrap()
.contains(&format!("at {MAX_LIMIT} matches")),
"note is {:?}",
out["note"]
);
let text = search_sessions(&sessions, args(json!({"query": "needle", "limit": 0})))
.expect("an answer");
let out: Value = serde_json::from_str(&text).unwrap();
assert_eq!(out["matches"].as_array().unwrap().len(), 1);
}
}