//! MCP tool declarations for repository and workspace server scopes.
//!
//! This is the discoverable agent surface. Runtime validation and output
//! envelopes remain owned by `agent_protocol`; execution remains in the
//! corresponding repository/workspace tool module.
use serde_json::{Value, json};
fn traversal_filters() -> Value {
json!({
"evidence": {"type": "array", "items": {"type": "string",
"enum": ["structural", "scope", "import", "scip", "declared", "dynamic"]},
"description": "restrict to these evidence kinds"},
"min_confidence": {"type": "string", "enum": ["certain", "inferred"],
"description": "certain = compiler-grade edges only"},
"relations": {"type": "array", "items": {"type": "string",
"enum": ["calls", "uses", "imports", "implements", "extends"]},
"description": "follow only these relations (e.g. drop file-level imports)"},
})
}
fn snapshot_precondition() -> Value {
json!({
"type": "string",
"description": "optional graph snapshot token from a prior response; stale tokens fail instead of resolving against changed graph state"
})
}
fn scope_filter(default: &[&str]) -> Value {
json!({
"type": "array",
"items": {"type": "string", "enum": [
"production", "test", "fixture", "example", "generated", "vendor", "docs", "all"
]},
"default": default,
"description": "corpus roles to return or traverse; `all` must be used alone"
})
}
pub(crate) fn repository() -> Value {
let filters = traversal_filters();
let mut list = json!({"tools": [
{
"name": "map",
"description": "Bounded structural inventory for an unfamiliar repository: node/edge totals, module node/file counts, dependency hubs ranked by non-containment in-degree, doc entry points, and graph-health limitations. Hubs are not runtime entry points or domain-ownership proof.",
"inputSchema": {"type": "object", "properties": {
"scope": scope_filter(&["production", "docs"]),
}},
},
{
"name": "ask",
"description": "Answer a vague or conceptual codebase question with explicit per-topic ranked hits and agent-safety metadata. `ranking_margin` is only a score gap; `confidence.calibration` reports the named holdout sample and measured precision. Obey each topic's `status`, `verify_required`, and `advice`: abstain means refine the query, verify means inspect evidence before acting. `limit` is a strict global hit budget across topics. Set `explain:true` only when per-hit scoring diagnostics are needed.",
"inputSchema": {"type": "object", "properties": {
"question": {"type": "string"},
"limit": {"type": "integer"},
"scope": scope_filter(&["production", "docs"]),
"explain": {"type": "boolean", "default": false,
"description": "include per-hit score_breakdown diagnostics"},
}, "required": ["question"]},
},
{
"name": "context",
"description": "Start here for a coding task: the smallest evidence packet before editing. Runs ask on the task text, expands the top contenders (show card, excerpt, direct deps, direct callers vs importers), selects relevant tests the way impact does, reports gaps (coverage, unresolved refs, ask abstain reason), and lists concrete next commands with stable handles. `outcome: abstain` means the task text did not rank a target; follow `next_actions`. Budgeted to 8000 bytes by default.",
"inputSchema": {"type": "object", "properties": {
"task": {"type": "string", "description": "task description in plain words"},
}, "required": ["task"]},
},
{
"name": "show",
"description": "Orient on one symbol: signature, doc, file, plus every incoming and outgoing edge with relation, evidence, and call site (`site`: file:line of the reference).",
"inputSchema": {"type": "object", "properties": {
"symbol": {"type": "string", "description": "stable symbol_key (preferred), name, qualified suffix, name@file-suffix, or snapshot-local node id"},
"if_snapshot": snapshot_precondition(),
}, "required": ["symbol"]},
},
{
"name": "query",
"description": "Find symbols by exact name, qualified name, or fuzzy match. Results carry signature, doc comment, file, and byte span.",
"inputSchema": {"type": "object", "properties": {
"symbol": {"type": "string", "description": "stable symbol_key (preferred), name, qualified suffix, name@file-suffix, or snapshot-local node id"},
"limit": {"type": "integer"},
"scope": scope_filter(&["all"]),
"if_snapshot": snapshot_precondition(),
}, "required": ["symbol"]},
},
{
"name": "affected",
"description": "Reverse blast radius: everything transitively depending on a symbol, cross-file. Summary-first: total, by_file (top files by dependent count), then dependents capped at `limit` (default 50; `truncated` reports how many were omitted). Terse dependent keys: s=qualified symbol, k=kind, f=file, e=relation/evidence, c=certain/possible, d=depth, site=file:line of the referencing site when known. Pass detail:true for full nodes within the limit. Pass `symbols` (array) to batch many symbols in one call — response is {results:[...]}, per-symbol errors inline. Every result carries snapshot plus coverage completeness, active filters, evidence availability, certain/possible counts, and unresolved gaps. Even a non-empty result is not runtime-exhaustive.",
"inputSchema": {"type": "object", "properties": {
"symbol": {"type": "string", "description": "stable symbol_key (preferred), name, qualified suffix, name@file-suffix, or snapshot-local node id"},
"symbols": {"type": "array", "items": {"type": "string"},
"description": "batch: blast radius for each; overrides `symbol`"},
"max_depth": {"type": "integer"},
"limit": {"type": "integer", "description": "max dependents returned (default 50)"},
"detail": {"type": "boolean", "description": "full node objects instead of terse entries"},
"evidence": filters["evidence"],
"min_confidence": filters["min_confidence"],
"relations": filters["relations"],
"scope": scope_filter(&["all"]),
"if_snapshot": snapshot_precondition(),
}},
},
{
"name": "deps",
"description": "Forward blast radius: everything a symbol transitively depends on (calls, uses, imports), cross-file. Summary-first: total, by_file, then dependencies capped at `limit` (default 50; `truncated` reports how many were omitted). Terse keys: s=qualified symbol, k=kind, f=file, e=relation/evidence, c=certain/possible, d=depth, site=file:line of the referencing site when known. Every result carries snapshot plus coverage completeness, active filters, evidence availability, certain/possible counts, and unresolved gaps. Even a non-empty result is not runtime-exhaustive.",
"inputSchema": {"type": "object", "properties": {
"symbol": {"type": "string", "description": "stable symbol_key (preferred), name, qualified suffix, name@file-suffix, or snapshot-local node id"},
"max_depth": {"type": "integer"},
"limit": {"type": "integer", "description": "max dependencies returned (default 50)"},
"evidence": filters["evidence"],
"min_confidence": filters["min_confidence"],
"relations": filters["relations"],
"scope": scope_filter(&["all"]),
"if_snapshot": snapshot_precondition(),
}, "required": ["symbol"]},
},
{
"name": "path",
"description": "Shortest dependency path from one symbol to another, with relation, evidence, confidence (certain/possible), and call site (`site`: file:line) for every step. Hits and misses both carry snapshot plus coverage completeness, active filters, evidence availability, and unresolved gaps. A static path is not proof of runtime reachability; `found:false` is never absence proof.",
"inputSchema": {"type": "object", "properties": {
"from": {"type": "string"},
"to": {"type": "string"},
"evidence": filters["evidence"],
"min_confidence": filters["min_confidence"],
"relations": filters["relations"],
"scope": scope_filter(&["all"]),
"if_snapshot": snapshot_precondition(),
}, "required": ["from", "to"]},
},
{
"name": "unresolved",
"description": "Where the graph is blind: references extraction saw but resolution never bound, each with file:line, enclosing symbol, and reason. Check this before treating an empty affected/deps/path result as a negative proof. Filter by `file` (repo-relative path) and/or `name` (referenced identifier).",
"inputSchema": {"type": "object", "properties": {
"file": {"type": "string"},
"name": {"type": "string"},
"limit": {"type": "integer", "description": "max references returned (default 50)"},
}},
},
{
"name": "impact",
"description": "Changed symbols, blast radius, and affected tests for a git rev range (e.g. HEAD~1..HEAD, main...branch). Each symbol collection returns at most 20 entries by default while authoritative totals and truncation counts remain available; set limit to 0 for all entries. A single rev (`HEAD`) covers uncommitted edits to tracked files in the working tree; untracked files are not included.",
"inputSchema": {"type": "object", "properties": {
"rev_range": {"type": "string"},
"limit": {"type": "integer", "minimum": 0, "default": 20, "description": "maximum entries returned independently for changed symbols, blast radius, and affected tests; 0 returns all entries"},
}, "required": ["rev_range"]},
},
{
"name": "overlap",
"description": "Rank pairwise merge risk between several in-flight changes (git rev ranges, e.g. open PRs). Tiers: direct = both touch the same symbol (textual or semantic collision); radius = one touches a symbol the other's touched code depends on (merges clean, breaks semantically); file = same file, disjoint symbols. Ranges accept `label=range` (e.g. pr-12=main...branch).",
"inputSchema": {"type": "object", "properties": {
"ranges": {"type": "array", "items": {"type": "string"}, "minItems": 2,
"description": "two or more rev-ranges, optionally labeled `label=range`"},
}, "required": ["ranges"]},
},
]});
crate::agent_protocol::complete_tool_schemas(&mut list);
list
}
pub(crate) fn workspace() -> Value {
let filters = traversal_filters();
let addressing = "Symbols accept `member:Symbol` (member from the workspace manifest) or any bare name that resolves uniquely across members.";
let mut list = json!({"tools": [
{
"name": "ask",
"description": "Answer a vague or conceptual question across every workspace member with the same calibrated per-topic contract as repository ask. Hits are merge-ranked and tagged with member before confidence is assessed. Obey each topic's `status`, `verify_required`, and `advice`; `limit` is a strict global hit budget. Set `explain:true` only when per-hit scoring diagnostics are needed.",
"inputSchema": {"type": "object", "properties": {
"question": {"type": "string"},
"limit": {"type": "integer"},
"scope": scope_filter(&["production", "docs"]),
"explain": {"type": "boolean", "default": false,
"description": "include per-hit score_breakdown diagnostics"},
}, "required": ["question"]},
},
{
"name": "show",
"description": format!("Orient on one symbol: signature, doc, file, every incoming and outgoing edge inside its member (with relation, evidence, and call site), plus boundary links into and out of the other members. {addressing}"),
"inputSchema": {"type": "object", "properties": {
"symbol": {"type": "string", "description": "member-qualified stable symbol_key (preferred), name, qualified suffix, name@file-suffix, or snapshot-local node id"},
"if_snapshot": snapshot_precondition(),
}, "required": ["symbol"]},
},
{
"name": "query",
"description": format!("Resolve a symbol across every workspace member. {addressing}"),
"inputSchema": {"type": "object", "properties": {
"symbol": {"type": "string", "description": "member-qualified stable symbol_key (preferred), name, qualified suffix, name@file-suffix, or snapshot-local node id"},
"scope": scope_filter(&["all"]),
"if_snapshot": snapshot_precondition(),
}, "required": ["symbol"]},
},
{
"name": "affected",
"description": format!("Cross-repository blast radius: everything transitively depending on a symbol across all workspace members, boundary links included. Summary-first: total, by_file, then dependents capped at `limit` (default 50; `truncated` reports omissions). Terse dependent keys: s=member:qualified symbol, k=kind, f=file, e=relation/evidence, c=certain/possible, p=parent. Pass detail:true for full nodes within the limit. Every result carries a workspace snapshot and member-attributed coverage gaps; unresolved_refs_matching_name > 0 means the list may be incomplete. {addressing}"),
"inputSchema": {"type": "object", "properties": {
"symbol": {"type": "string", "description": "member-qualified stable symbol_key (preferred), name, qualified suffix, name@file-suffix, or snapshot-local node id"},
"max_depth": {"type": "integer"},
"limit": {"type": "integer", "description": "max dependents returned (default 50)"},
"detail": {"type": "boolean", "description": "full node objects instead of terse entries"},
"evidence": filters["evidence"],
"min_confidence": filters["min_confidence"],
"relations": filters["relations"],
"scope": scope_filter(&["all"]),
"if_snapshot": snapshot_precondition(),
}, "required": ["symbol"]},
},
{
"name": "path",
"description": format!("Shortest dependency path between two symbols, crossing repository boundaries through import and declared links. Every step states certain/possible confidence, and hits and misses both carry a workspace snapshot plus member-attributed coverage gaps. {addressing}"),
"inputSchema": {"type": "object", "properties": {
"from": {"type": "string"},
"to": {"type": "string"},
"evidence": filters["evidence"],
"min_confidence": filters["min_confidence"],
"relations": filters["relations"],
"scope": scope_filter(&["all"]),
"if_snapshot": snapshot_precondition(),
}, "required": ["from", "to"]},
},
{
"name": "unresolved",
"description": "Where the graph is blind across the workspace: references extraction saw but resolution never bound, each tagged with its member (`member`, file as `member:path`). Check before treating an empty affected/deps/path result as a negative proof. Filter by `member`, `file` (member-relative path), and/or `name`.",
"inputSchema": {"type": "object", "properties": {
"member": {"type": "string"},
"file": {"type": "string"},
"name": {"type": "string"},
"limit": {"type": "integer", "description": "max references returned (default 50)"},
}},
},
{
"name": "impact",
"description": "Changed symbols, blast radius, and affected tests for a git rev range (e.g. HEAD~1..HEAD) in one member, with the radius continued across boundary links into the other members (cross-member entries carry a `member:` file prefix). Each symbol collection returns at most 20 entries by default while authoritative totals and truncation counts remain available; set limit to 0 for all entries.",
"inputSchema": {"type": "object", "properties": {
"member": {"type": "string", "description": "workspace member the rev range applies to"},
"rev_range": {"type": "string"},
"limit": {"type": "integer", "minimum": 0, "default": 20, "description": "maximum entries returned independently for changed symbols, blast radius, and affected tests; 0 returns all entries"},
}, "required": ["member", "rev_range"]},
},
]});
crate::agent_protocol::complete_tool_schemas(&mut list);
list
}
#[cfg(test)]
mod tests {
use super::{repository, workspace};
fn names(catalog: &serde_json::Value) -> Vec<&str> {
catalog["tools"]
.as_array()
.unwrap()
.iter()
.map(|tool| tool["name"].as_str().unwrap())
.collect()
}
#[test]
fn scope_catalogs_advertise_only_executable_tools() {
let repository = repository();
let workspace = workspace();
let repository_names = names(&repository);
let workspace_names = names(&workspace);
assert!(repository_names.contains(&"deps"));
assert!(repository_names.contains(&"map"));
assert!(!workspace_names.contains(&"deps"));
assert!(!workspace_names.contains(&"map"));
for tool in repository["tools"]
.as_array()
.unwrap()
.iter()
.chain(workspace["tools"].as_array().unwrap())
{
assert_eq!(tool["inputSchema"]["additionalProperties"], false);
assert!(tool["outputSchema"].is_object());
}
for catalog in [&repository, &workspace] {
let ask = catalog["tools"]
.as_array()
.unwrap()
.iter()
.find(|tool| tool["name"] == "ask")
.unwrap();
assert_eq!(
ask["inputSchema"]["properties"]["explain"]["type"],
"boolean"
);
assert_eq!(
ask["inputSchema"]["properties"]["explain"]["default"],
false
);
let impact = catalog["tools"]
.as_array()
.unwrap()
.iter()
.find(|tool| tool["name"] == "impact")
.unwrap();
assert_eq!(impact["inputSchema"]["properties"]["limit"]["minimum"], 0);
assert_eq!(impact["inputSchema"]["properties"]["limit"]["default"], 20);
}
}
}