use crate::mcp::CallOutcome;
use core_api::repograph::{
self, ImpactOptions, MapOptions, RememberInput, DEFAULT_EXCLUDES, MAX_OUTPUT_BYTES, NOTE_KINDS,
UNTRUSTED_FRAMING,
};
use core_api::{GraphError, SharedDb};
use serde_json::{json, Value as Js};
use std::collections::BTreeSet;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::Command;
const SYNC_KEY: &str = "__mushroomdb_git_sync__";
const SYNC_REPO_PROP: &str = "repo";
const PROJECT_DIR_VAR: &str = "CLAUDE_PROJECT_DIR";
const TASK_TOOLS: [&str; 8] = [
"map", "context", "impact", "owners", "why", "recall", "remember", "sync",
];
pub(crate) fn dispatch(
db: &SharedDb,
db_dir: Option<&Path>,
name: &str,
args: &Js,
) -> Option<CallOutcome> {
if !TASK_TOOLS.contains(&name) {
return None;
}
let json_out = match bool_arg(args, "json") {
Ok(b) => b,
Err(e) => return Some(CallOutcome::ToolErr(e)),
};
Some(match name {
"map" => tool_map(db, json_out),
"context" => tool_context(db, args, json_out),
"impact" => tool_impact(
db,
args,
std::env::var_os(PROJECT_DIR_VAR).as_deref(),
json_out,
),
"owners" => tool_owners(db, args, json_out),
"why" => tool_why(db, args, json_out),
"recall" => tool_recall(db, db_dir, args, json_out),
"remember" => tool_remember(db, args, json_out),
"sync" => tool_sync(db_dir, json_out),
_ => unreachable!("TASK_TOOLS and this match list the same eight names"),
})
}
fn ok<T: serde::Serialize>(
json_out: bool,
report: &T,
render: impl FnOnce(&T) -> String,
) -> CallOutcome {
if json_out {
return match serde_json::to_value(report) {
Ok(mut value) => {
sanitize_json(&mut value);
CallOutcome::TaskOk {
text: value.to_string(),
}
}
Err(e) => CallOutcome::ToolErr(format!("serialise report: {e}")),
};
}
let text = render(report);
let text = if text.starts_with(UNTRUSTED_FRAMING) {
text
} else {
format!("{UNTRUSTED_FRAMING}{text}")
};
CallOutcome::TaskOk { text }
}
fn sanitize_json(value: &mut Js) {
match value {
Js::String(s) => {
if s.chars().any(is_forbidden_control) {
*s = s
.chars()
.map(|c| if is_forbidden_control(c) { ' ' } else { c })
.collect();
}
}
Js::Array(items) => items.iter_mut().for_each(sanitize_json),
Js::Object(map) => map.values_mut().for_each(sanitize_json),
_ => {}
}
}
fn is_forbidden_control(c: char) -> bool {
c.is_ascii_control() && c != '\n' && c != '\t'
}
fn bool_arg(args: &Js, name: &str) -> Result<bool, String> {
match args.get(name) {
None | Some(Js::Null) => Ok(false),
Some(Js::Bool(b)) => Ok(*b),
Some(_) => Err(format!("{name} must be a boolean")),
}
}
fn str_arg<'a>(args: &'a Js, name: &str) -> Result<&'a str, String> {
args.get(name)
.and_then(Js::as_str)
.filter(|s| !s.is_empty())
.ok_or_else(|| format!("missing {name}"))
}
fn str_list_arg(args: &Js, name: &str) -> Result<Vec<String>, String> {
let Some(v) = args.get(name) else {
return Ok(Vec::new());
};
if v.is_null() {
return Ok(Vec::new());
}
let arr = v
.as_array()
.ok_or_else(|| format!("{name} must be an array of strings"))?;
arr.iter()
.map(|x| {
x.as_str()
.map(str::to_string)
.ok_or_else(|| format!("{name} must be an array of strings"))
})
.collect()
}
fn tool_map(db: &SharedDb, json_out: bool) -> CallOutcome {
let map = {
let g = db.read();
repograph::repo_map(&*g, &MapOptions::default())
};
ok(json_out, &map, repograph::render_map)
}
fn tool_context(db: &SharedDb, args: &Js, json_out: bool) -> CallOutcome {
let target = match str_arg(args, "target") {
Ok(t) => t,
Err(e) => return CallOutcome::ToolErr(e),
};
let report = {
let g = db.read();
repograph::context(&*g, None, target)
};
ok(json_out, &report, repograph::render_context)
}
fn tool_impact(
db: &SharedDb,
args: &Js,
project_dir: Option<&OsStr>,
json_out: bool,
) -> CallOutcome {
let mut files = match str_list_arg(args, "files") {
Ok(f) => f,
Err(e) => return CallOutcome::ToolErr(e),
};
if files.is_empty() {
let repo = match project_repo(db, project_dir) {
Some(r) => r,
None => {
return CallOutcome::ToolErr(
"no repository to read a diff from: pass files explicitly".into(),
)
}
};
match changed_paths(&repo) {
Ok(paths) => files = paths,
Err(e) => {
return CallOutcome::ToolErr(format!(
"could not read the diff in {}: {e}; pass files explicitly",
repo.display()
))
}
}
}
let modified: BTreeSet<String> = files.iter().cloned().collect();
let report = {
let g = db.read();
repograph::impact(&*g, &files, &modified, &ImpactOptions::default())
};
ok(json_out, &report, repograph::render_impact)
}
fn project_repo(db: &SharedDb, project_dir: Option<&OsStr>) -> Option<PathBuf> {
if let Some(root) = project_dir.map(Path::new).and_then(repo_root) {
return Some(root);
}
let repo = {
let g = db.read();
g.node_ref(SYNC_KEY)
.and_then(|n| n.prop(SYNC_REPO_PROP))
.and_then(|v| match v {
core_api::Value::Str(s) => Some(s),
_ => None,
})
}?;
repo_root(Path::new(&repo))
}
fn repo_root(dir: &Path) -> Option<PathBuf> {
if !dir.is_dir() {
return None;
}
let output = Command::new("git")
.arg("-C")
.arg(dir)
.args(["rev-parse", "--show-toplevel"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!root.is_empty()).then(|| PathBuf::from(root))
}
fn changed_paths(root: &Path) -> Result<Vec<String>, String> {
const LISTS: [&[&str]; 2] = [
&["diff", "--name-only", "-z", "HEAD"],
&["ls-files", "--others", "--exclude-standard", "-z"],
];
let excludes: Vec<String> = DEFAULT_EXCLUDES.iter().map(|p| (*p).to_string()).collect();
let mut out: BTreeSet<String> = BTreeSet::new();
let mut ran = false;
for args in LISTS {
let output = Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.output()
.map_err(|e| e.to_string())?;
if !output.status.success() {
continue;
}
ran = true;
for path in String::from_utf8_lossy(&output.stdout).split('\0') {
if !path.is_empty() && !repograph::path_excluded(path, &excludes) {
out.insert(path.to_string());
}
}
}
if !ran {
return Err("git listed nothing there".into());
}
Ok(out.into_iter().collect())
}
fn tool_owners(db: &SharedDb, args: &Js, json_out: bool) -> CallOutcome {
let path = match str_arg(args, "path") {
Ok(p) => p,
Err(e) => return CallOutcome::ToolErr(e),
};
let report = {
let g = db.read();
repograph::owners(&*g, path, None)
};
let Some(report) = report else {
return CallOutcome::ToolErr(format!("no file in the store at {path}"));
};
ok(json_out, &report, repograph::render_owners)
}
fn tool_why(db: &SharedDb, args: &Js, json_out: bool) -> CallOutcome {
let a = match str_arg(args, "a") {
Ok(v) => v.to_string(),
Err(e) => return CallOutcome::ToolErr(e),
};
let b = match str_arg(args, "b") {
Ok(v) => v.to_string(),
Err(e) => return CallOutcome::ToolErr(e),
};
let report = {
let g = db.read();
repograph::why(&*g, &a, &b)
};
ok(json_out, &report, repograph::render_why)
}
fn tool_recall(db: &SharedDb, db_dir: Option<&Path>, args: &Js, json_out: bool) -> CallOutcome {
let topic = match str_arg(args, "topic") {
Ok(t) => t.to_string(),
Err(e) => return CallOutcome::ToolErr(e),
};
let label = db_dir.map_or_else(|| "store".to_string(), |d| d.display().to_string());
let digest = match repograph::or_query(&topic) {
Some(query) => {
let g = db.read();
repograph::recall_digest(&*g, &query, &label, MAX_OUTPUT_BYTES)
}
None => String::new(),
};
let text = if digest.is_empty() {
format!(
"mushroomdb recall — nothing indexed matches {}\n",
repograph::sanitize(&topic)
)
} else {
digest.clone()
};
ok(
json_out,
&json!({ "topic": topic, "digest": digest }),
|_| text,
)
}
fn tool_remember(db: &SharedDb, args: &Js, json_out: bool) -> CallOutcome {
let text = match str_arg(args, "text") {
Ok(t) => t.to_string(),
Err(e) => return CallOutcome::ToolErr(e),
};
let mut about = match str_list_arg(args, "about") {
Ok(a) => a,
Err(e) => return CallOutcome::ToolErr(e),
};
about.sort();
about.dedup();
let kind = match args.get("kind") {
None | Some(Js::Null) => "note".to_string(),
Some(Js::String(k)) => k.clone(),
Some(_) => return CallOutcome::ToolErr("kind must be a string".into()),
};
if !NOTE_KINDS.contains(&kind.as_str()) {
return CallOutcome::ToolErr(format!(
"kind must be one of {}, got {kind:?}",
NOTE_KINDS.join(", ")
));
}
let missing: Vec<String> = {
let g = db.read();
about
.iter()
.filter(|k| !g.has_node(k))
.map(|k| repograph::sanitize(k))
.collect()
};
if !missing.is_empty() {
return CallOutcome::ToolErr(format!(
"unknown about {}: {}",
if missing.len() == 1 { "key" } else { "keys" },
missing.join(", ")
));
}
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64);
let input = RememberInput {
text: &text,
about: &about,
kind: &kind,
ts,
};
let key = {
let mut g = db.write();
repograph::remember(&mut *g, &input)
};
match key {
Ok(key) => {
let mut rendered = format!("remembered {}\n", repograph::sanitize(&key));
if !about.is_empty() {
rendered.push_str(&format!(
"about {}\n",
about
.iter()
.map(|k| repograph::sanitize(k))
.collect::<Vec<_>>()
.join(", ")
));
}
ok(
json_out,
&json!({ "key": key, "kind": kind, "about": about }),
|_| rendered,
)
}
Err(e) => CallOutcome::ToolErr(match e {
GraphError::QueryError { detail } | GraphError::IngestError { detail } => detail,
other => other.to_string(),
}),
}
}
fn tool_sync(db_dir: Option<&Path>, json_out: bool) -> CallOutcome {
let Some(db_dir) = db_dir else {
return CallOutcome::ToolErr(
"store path unknown: sync needs the directory this server was started on".into(),
);
};
let exe = match std::env::current_exe() {
Ok(exe) => exe,
Err(e) => return CallOutcome::ToolErr(format!("sync cannot find this binary: {e}")),
};
let output = match Command::new(&exe)
.arg("sync")
.arg(db_dir)
.arg("--json")
.output()
{
Ok(o) => o,
Err(e) => {
return CallOutcome::ToolErr(format!("sync could not run {}: {e}", exe.display()))
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let detail = stderr.trim();
let detail = if detail.is_empty() {
format!("exit {}", output.status)
} else {
repograph::sanitize(detail)
};
return CallOutcome::ToolErr(format!("sync failed: {detail}"));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let Ok(Js::Object(report)) = serde_json::from_str::<Js>(stdout.trim()) else {
return CallOutcome::ToolErr(format!(
"sync produced no report: {}",
repograph::sanitize(stdout.trim())
));
};
let text = report
.get("text")
.and_then(Js::as_str)
.unwrap_or_default()
.to_string();
ok(json_out, &Js::Object(report), |_| text)
}
fn json_arg() -> Js {
json!({
"type": "boolean",
"description": "Answer with the report as JSON, not the rendered digest."
})
}
pub(crate) fn task_tools() -> Vec<Js> {
let mut tools = task_tool_schemas();
for tool in &mut tools {
if let Some(props) = tool["inputSchema"]["properties"].as_object_mut() {
props.insert("json".to_string(), json_arg());
}
}
tools
}
fn task_tool_schemas() -> Vec<Js> {
vec![
json!({
"name": "map",
"description": "Summarise the graphed repository in one screen: size, last sync, clusters, key files, owners, hot files, stale concepts, and questions worth asking next. Start here when you do not know the codebase.",
"inputSchema": { "type": "object", "properties": {} }
}),
json!({
"name": "context",
"description": "Everything known about one file or symbol: signature, doc, source from the working tree, owner, every call site into it grouped by calling file, its callees, importers and imports, co-change partners, recent commits, and any notes or concepts about it.",
"inputSchema": {
"type": "object",
"properties": {
"target": {
"type": "string",
"minLength": 1,
"description": "A file path, a symbol key (path#name), or a bare symbol name. An ambiguous bare name returns the candidates instead."
}
},
"required": ["target"]
}
}),
json!({
"name": "impact",
"description": "What else the files in a change reach: co-change partners, by similarity score or by how many commits the two share, plus importers, symbols used elsewhere, and each file's owner. Defaults to the current git diff plus untracked files when no list is given.",
"inputSchema": {
"type": "object",
"properties": {
"files": {
"type": "array",
"items": { "type": "string" },
"description": "Repository-relative paths. Omit to use the working tree's diff against HEAD plus its untracked files."
}
}
}
}),
json!({
"name": "owners",
"description": "Who has written a file: top author and share, authors who know it, the last commit to touch it, and the split by quarter.",
"inputSchema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"minLength": 1,
"description": "Repository-relative file path."
}
},
"required": ["path"]
}
}),
json!({
"name": "why",
"description": "What links two files, symbols, or people, with the evidence for each link: shared commits, the importing line, every calling line, the file two authors both know. With no rule edge it reports the commits the two share, and failing that the shortest path between them.",
"inputSchema": {
"type": "object",
"properties": {
"a": { "type": "string", "minLength": 1, "description": "First node key." },
"b": { "type": "string", "minLength": 1, "description": "Second node key." }
},
"required": ["a", "b"]
}
}),
json!({
"name": "recall",
"description": "What the graph already knows about a topic: the closest notes, concepts, files, symbols and people, each with its strongest link.",
"inputSchema": {
"type": "object",
"properties": {
"topic": {
"type": "string",
"minLength": 1,
"description": "Free-form text. Searched as an OR of its words."
}
},
"required": ["topic"]
}
}),
json!({
"name": "remember",
"description": "Write a note into the graph and return its key. Keys listed in 'about' are linked to the note, and every one of them must already exist.",
"inputSchema": {
"type": "object",
"properties": {
"text": {
"type": "string",
"minLength": 1,
"description": "The note itself, 1 to 4000 characters."
},
"about": {
"type": "array",
"items": { "type": "string" },
"description": "Existing node keys the note is about: files, symbols, authors, concepts, other notes."
},
"kind": {
"type": "string",
"enum": ["note", "decision", "todo"],
"description": "What kind of note this is (default: note)."
}
},
"required": ["text"]
}
}),
json!({
"name": "sync",
"description": "Bring the store up to date with the repository it was built from: the commits since the last sync, then the files that differ from HEAD. Returns what changed.",
"inputSchema": { "type": "object", "properties": {} }
}),
]
}
#[cfg(test)]
mod tests {
use super::*;
use core_api::Value;
use std::sync::atomic::{AtomicU64, Ordering};
fn tmp(name: &str) -> PathBuf {
static SEQ: AtomicU64 = AtomicU64::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let d = std::env::temp_dir().join(format!("mcp-tasks-{name}-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
fn git(repo: &Path, args: &[&str]) {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.output()
.expect("git");
assert!(out.status.success(), "git {args:?}: {out:?}");
}
fn dirty_repo(name: &str) -> PathBuf {
let repo = tmp(name);
std::fs::create_dir_all(repo.join("src")).expect("src");
std::fs::create_dir_all(repo.join("target")).expect("target");
git(&repo, &["init", "-q"]);
git(&repo, &["config", "user.email", "t@example.test"]);
git(&repo, &["config", "user.name", "Test"]);
std::fs::write(repo.join("src/core.rs"), "fn init() {}\n").expect("write");
git(&repo, &["add", "src/core.rs"]);
git(&repo, &["commit", "-qm", "first"]);
std::fs::write(repo.join("src/core.rs"), "fn init() { /* edited */ }\n").expect("edit");
std::fs::write(repo.join("target/debug.log"), "noise\n").expect("artefact");
repo
}
fn store_for(name: &str, repo: Option<&Path>) -> (SharedDb, PathBuf) {
let dir = tmp(name);
let db = SharedDb::open(&dir).expect("open");
{
let mut w = db.write();
w.insert_node(
"File",
"src/core.rs",
vec![
("id".into(), Value::Str("src/core.rs".into())),
("path".into(), Value::Str("src/core.rs".into())),
("lines".into(), Value::Int(1)),
],
)
.expect("file");
let marker = repo.map_or_else(
|| "/nonexistent/mushroomdb-test-repo".to_string(),
|r| r.display().to_string(),
);
w.insert_node(
"GitSync",
SYNC_KEY,
vec![
("id".into(), Value::Str(SYNC_KEY.into())),
(SYNC_REPO_PROP.into(), Value::Str(marker)),
],
)
.expect("marker");
}
(db, dir)
}
fn report(outcome: &CallOutcome) -> Js {
match outcome {
CallOutcome::TaskOk { text } => {
serde_json::from_str(text).expect("a json reply is the serialised report")
}
other => panic!("expected a task result, got {}", describe(other)),
}
}
fn impact_files(outcome: &CallOutcome) -> Vec<String> {
report(outcome)["files"]
.as_array()
.expect("files")
.iter()
.map(|f| f["path"].as_str().expect("path").to_string())
.collect()
}
fn impact_report(db: &SharedDb, project_dir: Option<&OsStr>) -> CallOutcome {
tool_impact(db, &json!({"json": true}), project_dir, true)
}
fn describe(outcome: &CallOutcome) -> String {
match outcome {
CallOutcome::ToolErr(m) => format!("tool error: {m}"),
CallOutcome::TaskOk { text } => format!("ok: {text}"),
CallOutcome::ToolOk(v) => format!("json: {v}"),
CallOutcome::Protocol { message, .. } => format!("protocol: {message}"),
}
}
#[test]
fn default_files_come_from_the_marker_repo_and_skip_excluded_paths() {
let repo = dirty_repo("marker-repo");
let (db, dir) = store_for("marker-store", Some(&repo));
let outcome = impact_report(&db, None);
assert_eq!(
impact_files(&outcome),
vec!["src/core.rs".to_string()],
"the uncommitted edit, and not the build artefact"
);
assert_eq!(
report(&outcome)["unknown"],
json!([]),
"an excluded path must not come back as unknown"
);
drop(db);
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn the_project_directory_wins_over_the_marker() {
let project = dirty_repo("project-repo");
let (db, dir) = store_for("project-store", None);
let outcome = impact_report(&db, Some(project.as_os_str()));
assert_eq!(impact_files(&outcome), vec!["src/core.rs".to_string()]);
drop(db);
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&project);
}
#[test]
fn a_project_subdirectory_resolves_to_the_repository_root() {
let repo = dirty_repo("subdir-repo");
let (db, dir) = store_for("subdir-store", None);
let outcome = impact_report(&db, Some(repo.join("src").as_os_str()));
assert_eq!(
impact_files(&outcome),
vec!["src/core.rs".to_string()],
"paths stay root-relative, matching File keys"
);
drop(db);
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn a_project_directory_outside_a_checkout_falls_back_to_the_marker() {
let repo = dirty_repo("fallback-repo");
let plain = tmp("fallback-plain");
std::fs::create_dir_all(&plain).expect("plain dir");
let (db, dir) = store_for("fallback-store", Some(&repo));
let outcome = impact_report(&db, Some(plain.as_os_str()));
assert_eq!(impact_files(&outcome), vec!["src/core.rs".to_string()]);
drop(db);
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&plain);
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn no_checkout_anywhere_says_pass_files_explicitly() {
let (db, dir) = store_for("no-repo-store", None);
let outcome = impact_report(
&db,
Some(OsStr::new("/nonexistent/mushroomdb-test-project")),
);
match &outcome {
CallOutcome::ToolErr(m) => assert!(m.contains("pass files explicitly"), "{m}"),
other => panic!("{}", describe(other)),
}
drop(db);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn explicit_files_ignore_the_project_directory() {
let (db, dir) = store_for("explicit-store", None);
let outcome = tool_impact(
&db,
&json!({"files": ["src/core.rs"], "json": true}),
Some(OsStr::new("/nonexistent/mushroomdb-test-project")),
true,
);
assert_eq!(impact_files(&outcome), vec!["src/core.rs".to_string()]);
drop(db);
let _ = std::fs::remove_dir_all(&dir);
}
}