use std::net::SocketAddr;
use std::sync::Arc;
use rmcp::{
ServerHandler, ServiceExt,
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::{
CallToolResult, ContentBlock, Implementation, ProtocolVersion, ServerCapabilities,
ServerInfo,
},
tool, tool_handler, tool_router,
transport::{
stdio,
streamable_http_server::{
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
},
},
};
use rto_graph::{NodeKind, Store, StoreError, Workspace, debt, explain, list_kind, path, search};
use schemars::JsonSchema;
use serde::Deserialize;
type McpError = Box<dyn std::error::Error + Send + Sync>;
type SharedWorkspace = Arc<Workspace>;
#[derive(Debug, Deserialize, JsonSchema)]
struct ExplainArgs {
key: String,
#[serde(default)]
project: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct SearchArgs {
query: String,
#[serde(default)]
#[schemars(range(min = 1, max = 25))]
limit: Option<u32>,
#[serde(default)]
project: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct ListKindArgs {
kind: String,
#[serde(default)]
project: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct PathArgs {
from: String,
to: String,
#[serde(default)]
project: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct ContextArgs {
key: String,
#[serde(default)]
project: Option<String>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
struct CheckArgs {
#[serde(default)]
project: Option<String>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
struct DebtArgs {
#[serde(default)]
kind: Vec<String>,
#[serde(default)]
project: Option<String>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
struct DensityArgs {
#[serde(default)]
kind: Vec<String>,
#[serde(default)]
order: Option<String>,
#[serde(default)]
#[schemars(range(min = 1, max = 100))]
limit: Option<u32>,
#[serde(default)]
min_lines: Option<u32>,
#[serde(default)]
project: Option<String>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
struct ConfigSecretArgs {
#[serde(default)]
#[schemars(range(min = 1, max = 200))]
limit: Option<u32>,
#[serde(default)]
project: Option<String>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
struct CouplingArgs {
#[serde(default)]
order: Option<String>,
#[serde(default)]
#[schemars(range(min = 1, max = 100))]
limit: Option<u32>,
#[serde(default)]
project: Option<String>,
}
#[derive(Clone)]
struct GraphServer {
workspace: SharedWorkspace,
#[allow(dead_code)]
tool_router: ToolRouter<Self>,
}
impl GraphServer {
fn new(workspace: SharedWorkspace) -> Self {
Self {
workspace,
tool_router: Self::tool_router(),
}
}
fn with_project<R>(
&self,
project: Option<&str>,
f: impl FnOnce(&Store) -> R,
) -> Result<R, String> {
self.workspace
.with_store(project, f)
.map_err(|e| e.to_string())
}
}
fn query_result<T: serde::Serialize>(r: Result<Result<T, StoreError>, String>) -> CallToolResult {
match r {
Ok(Ok(value)) => json_result(&value),
Ok(Err(e)) => tool_error(&format!("query error: {e}")),
Err(e) => tool_error(&e),
}
}
fn model_limit(given: Option<u32>, default: u32, max: u32) -> usize {
usize::try_from(given.unwrap_or(default).clamp(1, max)).unwrap_or(1)
}
fn qualified_or(key: &str, project: Option<&str>) -> (Option<String>, String) {
rto_graph::parse_qualified(key).map_or_else(
|| (project.map(str::to_owned), key.to_owned()),
|(p, bare)| (Some(p.to_owned()), bare.to_owned()),
)
}
#[tool_router]
impl GraphServer {
#[tool(description = "Explain a graph node: its record and its \
provenance-labelled incoming/outgoing edges. \
Keys: sym:<lang>:<path>#<Name>, file:<path>, adr:<id>. \
A key may be project-qualified (<project>::<key>) to follow a \
cross-repo link into another hosted project (see list_projects).")]
async fn explain(&self, Parameters(args): Parameters<ExplainArgs>) -> CallToolResult {
let (proj, bare) = qualified_or(&args.key, args.project.as_deref());
let result = self.with_project(proj.as_deref(), |store| explain(store, &bare));
match result {
Ok(Ok(Some(ex))) => json_result(&ex),
Ok(Ok(None)) => CallToolResult::success(vec![ContentBlock::text(format!(
"no node with key `{}`",
args.key
))]),
Ok(Err(e)) => tool_error(&format!("query error: {e}")),
Err(e) => tool_error(&e),
}
}
#[tool(
description = "Search graph nodes by text — names, keys, paths, and captured \
content (doc comments, README/ADR/blueprint prose). Returns \
ranked hits with keys; curated ADRs/blueprints and READMEs rank \
first, so it's the entry point for \"what is X / why\" questions. \
Then `explain` a returned key. Args: query, optional limit \
(1-25, default 10 — there is no unlimited setting; narrow the \
query instead of asking for more)."
)]
async fn search(&self, Parameters(args): Parameters<SearchArgs>) -> CallToolResult {
let limit = model_limit(args.limit, 10, 25);
query_result(self.with_project(args.project.as_deref(), |store| {
search(store, &args.query, limit)
}))
}
#[tool(
description = "Fetch a node's CONTEXT BUNDLE: the node, its metadata, and its \
one-hop provenance-labelled neighbourhood, with a validity \
`fingerprint` that moves when the node or any neighbour changes. \
The grounding to answer \"what is this and what is it wired to\" \
from. Args: key (the only argument). \
BOUNDED, and it tells you when it bound something: each direction \
carries at most 50 edges. When more exist, `truncated` is true, \
`outgoing.total`/`incoming.total` give the real counts, and \
`omitted` names each edge kind and how many of it are missing — \
so an absent `imports` edge means there are none, and a large \
file's missing definitions are counted rather than silently \
dropped. Read `omitted` before concluding anything from an \
absence, and use `explain` or `search` to reach what was left \
out."
)]
async fn context(&self, Parameters(args): Parameters<ContextArgs>) -> CallToolResult {
let (proj, bare) = qualified_or(&args.key, args.project.as_deref());
let result = self.with_project(proj.as_deref(), |store| {
rto_graph::tool_context(store, &bare)
});
match result {
Ok(Ok(Some(ctx))) => json_result(&ctx),
Ok(Ok(None)) => CallToolResult::success(vec![ContentBlock::text(format!(
"no node with key `{}`",
args.key
))]),
Ok(Err(e)) => tool_error(&format!("query error: {e}")),
Err(e) => tool_error(&e),
}
}
#[tool(
description = "Run the AUTHORED-LAYER DRIFT CHECK — the same gate `roteiro check` \
exits non-zero on and the pre-commit hook reads — and return its \
verdict as data: ADR `[[path#Symbol]]` links that no longer \
resolve, `@rto:` annotations pointing at unknown or superseded \
ADRs, malformed ADRs, and duplicate `adr-id`s. \
READ `gate` FIRST. It is `pass`, `fail`, or `not-run`, and \
`not-run` is a real outcome: a check needs the project's \
repository on disk and a graph synced from the current HEAD, and \
when it cannot have both it refuses rather than answering about a \
tree that is nobody's. A `not-run` result carries NO `report` at \
all — so if you are looking for `violations` and there is no \
`report`, nothing was checked and you must say so rather than \
report a clean repository. `not_run_reason` says what to fix \
(usually: run `roteiro sync`). \
This is read-only: it does not rebuild the graph, which is the \
one thing the CLI gate does that this cannot."
)]
async fn check(&self, Parameters(args): Parameters<CheckArgs>) -> CallToolResult {
let project = args.project.as_deref();
let root = match self.workspace.project_root(project) {
Ok(root) => root,
Err(e) => return tool_error(&e.to_string()),
};
query_result(self.with_project(project, |store| {
rto_spec::tool_check(store, root.as_deref())
}))
}
#[tool(description = "List all nodes of a given kind (fn, struct, enum, \
trait, module, file, adr, …).")]
async fn list_kind(&self, Parameters(args): Parameters<ListKindArgs>) -> CallToolResult {
query_result(self.with_project(args.project.as_deref(), |store| {
list_kind(store, &NodeKind::from_token(&args.kind))
}))
}
#[tool(
description = "Find a shortest path between two graph nodes, following \
edges in either direction. Each hop records the edge kind, \
provenance, and traversal direction (outgoing/incoming). \
Args: from, to (node keys). A path lives within one project: \
a project-qualified `from` (<project>::<key>) selects that \
project (see list_projects)."
)]
async fn path(&self, Parameters(args): Parameters<PathArgs>) -> CallToolResult {
let (proj, from_bare) = qualified_or(&args.from, args.project.as_deref());
let to_bare = rto_graph::parse_qualified(&args.to)
.map_or_else(|| args.to.clone(), |(_, b)| b.to_owned());
query_result(self.with_project(proj.as_deref(), |store| path(store, &from_bare, &to_bare)))
}
#[tool(
description = "List intent-debt markers found in the codebase — TODO/FIXME/HACK \
comments, todo!()/unimplemented!() stubs, and deferred-work notes — \
grouped by category (todo, fixme, hack, stub, deferred). Optional \
`kind` restricts to given categories. Each marker links to its \
enclosing symbol or file via a `contains` edge."
)]
async fn debt(&self, Parameters(args): Parameters<DebtArgs>) -> CallToolResult {
query_result(self.with_project(args.project.as_deref(), |store| {
debt(store, &args.kind, &[])
}))
}
#[tool(
description = "Rank FILES by intent-debt DENSITY — markers per 1,000 lines — rather \
than by raw marker count, which ranks the biggest file first by \
construction. Each row carries `markers`, `lines`, `per_kloc` and a \
per-category split; `overall_per_kloc` is the repository baseline to \
read a file's figure against. Args: kind, order (density|markers|\
lines), limit (1-100, default 20 — no unlimited setting), \
min_lines. \
Two limits worth passing on to the user rather than reporting a \
number as a finding. The denominator is FILE LENGTH — every line, \
blanks and comments included — not source lines of code, so figures \
run lower than an SLOC tool's and flatter verbose or generated \
files. And the markers beneath it include prose matches (`for now`, \
`placeholder`, `tbd`), so a design document can rank as dense debt. \
This is a measurement, not a gate."
)]
async fn debt_density(&self, Parameters(args): Parameters<DensityArgs>) -> CallToolResult {
let limit = model_limit(args.limit, 20, 100);
let min_lines = args.min_lines.unwrap_or(rto_graph::DEFAULT_MIN_LINES);
let order = match args.order.as_deref() {
None => rto_graph::DensityOrder::default(),
Some(token) => match rto_graph::DensityOrder::from_token(token) {
Some(order) => order,
None => {
return tool_error(&format!(
"unknown order `{token}` (expected {})",
rto_graph::DensityOrder::tokens().join("|")
));
}
},
};
query_result(self.with_project(args.project.as_deref(), |store| {
rto_graph::debt_density(store, &args.kind, &[], order, limit, min_lines)
}))
}
#[tool(
description = "Inventory the SECRET-NAMED config keys in the graph: their file \
paths, their key names, and whether each value was redacted \
before being stored (`state` = redacted | declared | present). \
Answers \"which of this repo's config surfaces deal in \
credentials\" and \"did anything unredacted get into this \
graph\". Args: limit (1-200, default 50 — no unlimited \
setting). \
THIS IS NOT A SECRET SCANNER — state the limits when you \
report it, and never imply a security guarantee. It CANNOT \
find a hardcoded credential in source code: it reads config-key \
nodes, so a token in a Rust or Python string literal produces \
nothing here and is invisible. It CANNOT judge whether a value \
is valid, because it never sees one — values are redacted \
before they reach the store. It CANNOT tell a real secret from \
a placeholder: `API_TOKEN=changeme` in a committed \
`.env.example` and a live token are the same row. And an EMPTY \
RESULT DOES NOT MEAN THERE ARE NO SECRETS — it means no config \
key is secret-NAMED; a credential under an innocuous key like \
`dsn` or `endpoint` never appears. If asked to scan for \
secrets, say plainly that this tool cannot do it."
)]
async fn config_secrets(
&self,
Parameters(args): Parameters<ConfigSecretArgs>,
) -> CallToolResult {
let limit = model_limit(args.limit, 50, 200);
query_result(self.with_project(args.project.as_deref(), |store| {
rto_graph::config_secrets(store, limit)
}))
}
#[tool(
description = "Rank symbols by DIRECTED call coupling over `calls` edges: `fan_in` \
(how many distinct symbols call this one), `fan_out` (how many it \
calls), and `instability` = fan_out/(fan_in+fan_out). Use \
`order`=fan_in to find what the codebase most depends on, \
`order`=fan_out for the symbols that reach furthest, `total` \
(default) for overall coupling. Args: order, limit (1-100, \
default 20 — no unlimited setting). \
Caveat worth passing on to the user: call edges are resolved by \
simple name, so a short generically-named function can absorb \
every call to that name and show an inflated `fan_in`. Treat a \
high figure on such a symbol as a question, not a finding."
)]
async fn coupling(&self, Parameters(args): Parameters<CouplingArgs>) -> CallToolResult {
let limit = model_limit(args.limit, 20, 100);
let order = match args.order.as_deref() {
None => rto_graph::CouplingOrder::default(),
Some(token) => match rto_graph::CouplingOrder::from_token(token) {
Some(order) => order,
None => {
return tool_error(&format!(
"unknown order `{token}` (expected {})",
rto_graph::CouplingOrder::tokens().join("|")
));
}
},
};
query_result(self.with_project(args.project.as_deref(), |store| {
rto_graph::coupling(store, order, limit)
}))
}
#[tool(
description = "List the projects this server hosts. Pass one as `project` to the \
other tools to query it. A single-project server needs no `project`."
)]
async fn list_projects(&self) -> CallToolResult {
json_result(&serde_json::json!({ "projects": self.workspace.names() }))
}
}
#[tool_handler]
impl ServerHandler for GraphServer {
fn get_info(&self) -> ServerInfo {
let mut info = ServerInfo::default();
info.protocol_version = ProtocolVersion::default();
info.capabilities = ServerCapabilities::builder().enable_tools().build();
info.server_info = Implementation::new("roteiro", env!("CARGO_PKG_VERSION"));
info.instructions = Some(
"Roteiro codebase knowledge graph. Start with `search` to find nodes by \
text (it searches captured content too — README/ADR/blueprint prose — \
and ranks curated docs first, so it answers \"what is X / why\"); then \
`explain` a key for its provenance-labelled neighbourhood, or `context` \
for the same neighbourhood bounded and fingerprinted. `list_kind` \
enumerates a kind, `path` finds how two nodes connect, `debt` lists \
intent-debt markers, `debt_density` ranks files by markers per 1,000 \
lines, `coupling` ranks symbols by directed call fan-in/fan-out, \
`config_secrets` inventories secret-named config keys (an inventory, \
not a secret scan — see its description), and `check` runs the \
authored-layer drift gate and returns its verdict as data (read its \
`gate` field: `not-run` is a real outcome and is not a clean \
repository). Every tool here is read-only. There is no `review` tool — \
`roteiro review` is CLI-first and needs no server; see this module's \
documentation for why it is not exposed."
.into(),
);
info
}
}
#[must_use]
pub fn tool_names() -> Vec<String> {
let mut names: Vec<String> = GraphServer::tool_router()
.list_all()
.into_iter()
.map(|t| t.name.to_string())
.collect();
names.sort();
names
}
fn tool_error(message: &str) -> CallToolResult {
CallToolResult::error(vec![ContentBlock::text(message.to_owned())])
}
fn json_result<T: serde::Serialize>(value: &T) -> CallToolResult {
match serde_json::to_string_pretty(value) {
Ok(text) => CallToolResult::success(vec![ContentBlock::text(text)]),
Err(e) => tool_error(&format!("serialize error: {e}")),
}
}
fn runtime() -> std::io::Result<tokio::runtime::Runtime> {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
}
pub fn serve_stdio(workspace: Arc<Workspace>) -> Result<(), McpError> {
let shared: SharedWorkspace = workspace;
runtime()?.block_on(async move {
let service = GraphServer::new(shared).serve(stdio()).await?;
service.waiting().await?;
Ok(())
})
}
pub fn mcp_router(workspace: Arc<Workspace>) -> axum::Router {
let shared: SharedWorkspace = workspace;
let service = StreamableHttpService::new(
move || Ok(GraphServer::new(shared.clone())),
Arc::new(LocalSessionManager::default()),
StreamableHttpServerConfig::default(),
);
axum::Router::new().nest_service("/mcp", service)
}
pub fn serve_http(workspace: Arc<Workspace>, addr: SocketAddr) -> Result<(), McpError> {
let router = mcp_router(workspace);
runtime()?.block_on(async move {
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, router).await?;
Ok(())
})
}
#[cfg(test)]
mod tests {
use super::{
CheckArgs, ConfigSecretArgs, ContextArgs, CouplingArgs, DebtArgs, DensityArgs, ExplainArgs,
GraphServer, ListKindArgs, PathArgs, SearchArgs, model_limit,
};
use rmcp::ServerHandler;
use rmcp::handler::server::wrapper::Parameters;
use std::sync::Arc;
use rto_graph::{Edge, EdgeKind, FactSet, Node, NodeKind, Store, Workspace};
fn seeded() -> GraphServer {
let mut store = Store::open_in_memory().expect("store");
let mut marker = Node::new("marker:a.rs#7", NodeKind::Marker, "TODO wire this up");
marker.meta =
serde_json::json!({ "category": "todo", "text": "TODO wire this up", "line": 7 });
marker.path = Some("a.rs".into());
let mut file = Node::new("file:a.rs", NodeKind::File, "a.rs");
file.path = Some("a.rs".into());
file.meta = serde_json::json!({ "bytes": 2000, "lines": 100 });
let cfg = |dotted: &str, value: &str| {
let mut n = Node::new(
format!("cfgkey:.env#{dotted}"),
NodeKind::Other("config_key".to_owned()),
dotted,
);
n.path = Some(".env".into());
n.meta = serde_json::json!({ "key": dotted, "value": value });
n
};
let facts = FactSet::new()
.with_node(file)
.with_node(cfg("API_TOKEN", "<redacted>"))
.with_node(cfg("PORT", "8017"))
.with_node(Node::new("sym:rust:a.rs#main", NodeKind::Fn, "main"))
.with_node(Node::new("sym:rust:a.rs#helper", NodeKind::Fn, "helper"))
.with_node(marker)
.with_edge(Edge::derived(
"sym:rust:a.rs#main",
"sym:rust:a.rs#helper",
EdgeKind::Calls,
))
.with_edge(Edge::derived(
"sym:rust:a.rs#main",
"marker:a.rs#7",
EdgeKind::Contains,
));
store.apply_factset(&facts).expect("apply");
GraphServer::new(Arc::new(Workspace::single("test", store)))
}
fn text_of(result: &rmcp::model::CallToolResult) -> String {
result
.content
.iter()
.filter_map(|c| c.as_text().map(|t| t.text.clone()))
.collect()
}
#[tokio::test]
async fn explain_tool_returns_graph_json() {
let server = seeded();
let out = server
.explain(Parameters(ExplainArgs {
key: "sym:rust:a.rs#main".into(),
project: None,
}))
.await;
let text = text_of(&out);
let json: serde_json::Value = serde_json::from_str(&text).expect("json");
assert_eq!(json["node"]["key"], "sym:rust:a.rs#main");
assert_eq!(json["outgoing"][0]["node"], "sym:rust:a.rs#helper");
assert_eq!(json["outgoing"][0]["provenance"], "derived");
}
#[tokio::test]
async fn list_kind_tool_lists_nodes() {
let server = seeded();
let out = server
.list_kind(Parameters(ListKindArgs {
kind: "fn".into(),
project: None,
}))
.await;
let text = text_of(&out);
assert!(text.contains("sym:rust:a.rs#helper"));
assert!(text.contains("sym:rust:a.rs#main"));
}
#[tokio::test]
async fn search_tool_finds_nodes_by_text() {
let server = seeded();
let out = server
.search(Parameters(SearchArgs {
query: "helper".into(),
limit: None,
project: None,
}))
.await;
let text = text_of(&out);
assert!(text.contains("sym:rust:a.rs#helper"), "{text}");
}
#[tokio::test]
async fn explain_missing_node_is_not_an_error() {
let server = seeded();
let out = server
.explain(Parameters(ExplainArgs {
key: "sym:rust:a.rs#ghost".into(),
project: None,
}))
.await;
assert!(text_of(&out).contains("no node with key"));
}
#[tokio::test]
async fn path_tool_returns_connecting_path() {
let server = seeded();
let out = server
.path(Parameters(PathArgs {
from: "sym:rust:a.rs#main".into(),
to: "sym:rust:a.rs#helper".into(),
project: None,
}))
.await;
let json: serde_json::Value = serde_json::from_str(&text_of(&out)).expect("json");
assert_eq!(json["found"], true);
assert_eq!(json["length"], 1);
assert_eq!(json["hops"][0]["node"], "sym:rust:a.rs#helper");
assert_eq!(json["hops"][0]["provenance"], "derived");
}
#[tokio::test]
async fn debt_tool_lists_and_filters_markers() {
let server = seeded();
let all = text_of(&server.debt(Parameters(DebtArgs::default())).await);
let json: serde_json::Value = serde_json::from_str(&all).expect("json");
assert_eq!(json["total"], 1);
assert_eq!(json["by_category"]["todo"], 1);
assert_eq!(json["items"][0]["key"], "marker:a.rs#7");
assert_eq!(json["items"][0]["line"], 7);
let none = text_of(
&server
.debt(Parameters(DebtArgs {
kind: vec!["stub".into()],
project: None,
}))
.await,
);
let json: serde_json::Value = serde_json::from_str(&none).expect("json");
assert_eq!(json["total"], 0);
}
#[tokio::test]
async fn debt_density_tool_normalises_by_file_length() {
let server = seeded();
let out = text_of(
&server
.debt_density(Parameters(DensityArgs::default()))
.await,
);
let json: serde_json::Value = serde_json::from_str(&out).expect("json");
assert_eq!(json["order"], "density");
assert_eq!(json["items"][0]["path"], "a.rs");
assert_eq!(json["items"][0]["markers"], 1);
assert_eq!(json["items"][0]["lines"], 100, "from the file node: {json}");
assert_eq!(
json["items"][0]["per_kloc"], 10.0,
"1 marker in 100 lines is 10 per 1,000: {json}"
);
assert_eq!(json["items"][0]["by_category"]["todo"], 1);
let out = text_of(
&server
.debt_density(Parameters(DensityArgs {
min_lines: Some(500),
..DensityArgs::default()
}))
.await,
);
let json: serde_json::Value = serde_json::from_str(&out).expect("json");
assert_eq!(json["short_files"], 1);
assert_eq!(json["files_with_markers"], 1);
assert_eq!(json["items"].as_array().map(Vec::len), Some(0));
}
#[tokio::test]
async fn debt_density_tool_errors_rather_than_silently_reordering() {
let out = seeded()
.debt_density(Parameters(DensityArgs {
order: Some("count".into()),
..DensityArgs::default()
}))
.await;
assert_eq!(out.is_error, Some(true), "{out:?}");
assert!(text_of(&out).contains("unknown order `count`"), "{out:?}");
}
#[tokio::test]
async fn config_secrets_tool_reports_presence_and_state_never_a_value() {
let out = text_of(
&seeded()
.config_secrets(Parameters(ConfigSecretArgs::default()))
.await,
);
let json: serde_json::Value = serde_json::from_str(&out).expect("json");
assert_eq!(json["config_keys"], 2, "the population: {json}");
assert_eq!(json["secret_named"], 1, "only `API_TOKEN` is: {json}");
assert_eq!(json["redacted"], 1);
assert_eq!(json["unredacted"], 0);
assert_eq!(json["items"][0]["name"], "API_TOKEN");
assert_eq!(json["items"][0]["path"], ".env");
assert_eq!(json["items"][0]["state"], "redacted");
assert!(
json["items"][0].get("value").is_none() && !out.contains("<redacted>"),
"the tool reports presence and state, never a value: {out}"
);
}
#[test]
fn config_secrets_tool_description_refuses_the_scanner_reading() {
let server = seeded();
let tool = server
.tool_router
.list_all()
.into_iter()
.find(|t| t.name == "config_secrets")
.expect("`config_secrets` advertised");
let desc = tool.description.as_deref().unwrap_or_default();
for claim in [
"NOT A SECRET SCANNER",
"CANNOT find a hardcoded credential in source code",
"never sees one",
"real secret from a placeholder",
"EMPTY RESULT DOES NOT MEAN THERE ARE NO SECRETS",
] {
assert!(desc.contains(claim), "missing `{claim}` from: {desc}");
}
}
#[tokio::test]
async fn coupling_tool_separates_the_two_directions() {
let server = seeded();
let by_in = text_of(
&server
.coupling(Parameters(CouplingArgs {
order: Some("fan_in".into()),
limit: Some(1),
project: None,
}))
.await,
);
let json: serde_json::Value = serde_json::from_str(&by_in).expect("json");
assert_eq!(json["order"], "fan_in");
assert_eq!(json["items"][0]["key"], "sym:rust:a.rs#helper");
let by_out = text_of(
&server
.coupling(Parameters(CouplingArgs {
order: Some("fan_out".into()),
limit: Some(1),
project: None,
}))
.await,
);
let json: serde_json::Value = serde_json::from_str(&by_out).expect("json");
assert_eq!(json["items"][0]["key"], "sym:rust:a.rs#main");
}
#[tokio::test]
async fn coupling_tool_errors_rather_than_silently_reordering() {
let out = seeded()
.coupling(Parameters(CouplingArgs {
order: Some("degree".into()),
limit: None,
project: None,
}))
.await;
assert_eq!(out.is_error, Some(true), "{out:?}");
assert!(text_of(&out).contains("unknown order `degree`"), "{out:?}");
}
#[test]
fn a_model_limit_of_zero_floors_to_one_page_and_never_to_nothing() {
for (default, max, largest, page) in
[(10, 25, 25, 10), (20, 100, 100, 20), (50, 200, 200, 50)]
{
assert_eq!(
model_limit(Some(0), default, max),
1,
"0 is the smallest page, not unlimited and not nothing",
);
assert_eq!(model_limit(Some(u32::MAX), default, max), largest);
assert_eq!(model_limit(None, default, max), page);
assert_eq!(model_limit(Some(3), default, max), 3);
}
}
#[test]
fn every_limit_tool_advertises_the_bound_it_enforces() {
let server = seeded();
let tools = server.tool_router.list_all();
for (name, max) in [
("search", 25u64),
("debt_density", 100),
("config_secrets", 200),
("coupling", 100),
] {
let tool = tools
.iter()
.find(|t| t.name == name)
.unwrap_or_else(|| panic!("`{name}` advertised"));
let limit = tool
.input_schema
.get("properties")
.and_then(|p| p.get("limit"))
.unwrap_or_else(|| panic!("`{name}` declares a `limit` parameter"));
assert_eq!(
limit.get("minimum").and_then(serde_json::Value::as_u64),
Some(1),
"`{name}` must not advertise `0` as a legal limit",
);
assert_eq!(
limit.get("maximum").and_then(serde_json::Value::as_u64),
Some(max),
"`{name}` must advertise the ceiling it clamps to",
);
let desc = tool.description.as_deref().unwrap_or_default();
assert!(
desc.contains(&format!("1-{max}")),
"`{name}` description must state its range: {desc}",
);
assert!(
desc.contains("no unlimited setting"),
"`{name}` description must say `0`/unlimited is not offered: {desc}",
);
}
}
#[tokio::test]
async fn context_tool_returns_the_bounded_bundle() {
let server = seeded();
let out = server
.context(Parameters(ContextArgs {
key: "sym:rust:a.rs#main".into(),
project: None,
}))
.await;
let json: serde_json::Value = serde_json::from_str(&text_of(&out)).expect("json");
assert_eq!(json["node"]["key"], "sym:rust:a.rs#main");
assert_eq!(json["edge_cap"], rto_graph::TOOL_CONTEXT_EDGE_CAP);
assert_eq!(json["truncated"], false);
assert_eq!(json["outgoing"]["total"], 2, "{json}");
assert_eq!(json["outgoing"]["truncated"], false);
assert!(
json["outgoing"]["omitted"]
.as_array()
.is_some_and(Vec::is_empty)
);
assert!(
json["outgoing"]["edges"]
.as_array()
.is_some_and(|a| a.iter().any(|e| e["node"] == "sym:rust:a.rs#helper")),
"{json}"
);
assert!(json["fingerprint"].as_str().is_some_and(|f| !f.is_empty()));
}
#[tokio::test]
async fn context_missing_node_is_not_an_error() {
let out = seeded()
.context(Parameters(ContextArgs {
key: "sym:rust:a.rs#ghost".into(),
project: None,
}))
.await;
assert_eq!(out.is_error, Some(false), "{out:?}");
assert!(text_of(&out).contains("no node with key"));
}
#[tokio::test]
async fn context_tool_never_writes_to_the_store() {
let server = seeded();
server
.workspace
.with_store(None, |store| {
store
.context_cache_put("sym:rust:a.rs#ghost", "stale", "{}")
.expect("put");
})
.expect("store");
for key in ["sym:rust:a.rs#main", "sym:rust:a.rs#ghost"] {
server
.context(Parameters(ContextArgs {
key: key.into(),
project: None,
}))
.await;
}
let keys = server
.workspace
.with_store(None, |store| store.context_cache_keys().expect("keys"))
.expect("store");
assert_eq!(
keys,
vec!["sym:rust:a.rs#ghost".to_owned()],
"a tool read must neither populate nor prune the context cache",
);
}
#[tokio::test]
async fn check_tool_reports_not_run_rather_than_a_clean_repository() {
let out = seeded().check(Parameters(CheckArgs::default())).await;
assert_eq!(
out.is_error,
Some(false),
"not-run is data, not a tool error"
);
let json: serde_json::Value = serde_json::from_str(&text_of(&out)).expect("json");
assert_eq!(json["schema"], rto_spec::TOOL_CHECK_SCHEMA);
assert_eq!(json["gate"], "not-run");
assert!(
json.get("report").is_none(),
"a not-run check must carry no report at all: {json}"
);
assert!(
json.pointer("/report/violations").is_none(),
"`0 violations` must be unreachable when nothing ran: {json}"
);
assert!(
json["not_run_reason"]
.as_str()
.is_some_and(|r| !r.is_empty()),
"{json}"
);
}
#[test]
fn check_tool_description_refuses_the_advisory_reading() {
let server = seeded();
let tool = server
.tool_router
.list_all()
.into_iter()
.find(|t| t.name == "check")
.expect("`check` advertised");
let desc = tool.description.as_deref().unwrap_or_default();
for claim in [
"READ `gate` FIRST",
"`not-run` is a real outcome",
"carries NO `report`",
"rather than report a clean repository",
] {
assert!(desc.contains(claim), "missing `{claim}` from: {desc}");
}
}
#[test]
fn review_is_not_exposed_and_the_reason_is_recorded_here() {
let server = seeded();
assert!(
!server
.tool_router
.list_all()
.iter()
.any(|t| t.name == "review"),
"`review` must not be an MCP tool: it is ~435 KB for a three-commit \
range, and its per-file `debt` cannot apply the target project's \
`[debt] ignore` from this crate (issue #321). See the module docs.",
);
}
#[test]
fn no_security_subcommand_is_exposed() {
let server = seeded();
let security: Vec<String> = server
.tool_router
.list_all()
.into_iter()
.map(|t| t.name.to_string())
.filter(|n| n.starts_with("security"))
.collect();
assert!(
security.is_empty(),
"`security ingest`/`run`/`prefetch` are permanent refusals (mutating, \
executing, network-consented); `list`/`status` are eligible but need \
the never-run-vs-clean discriminator and a `project` selector first. \
Found: {security:?}",
);
}
#[test]
fn every_context_tool_states_its_fixed_bound() {
let server = seeded();
let tools = server.tool_router.list_all();
let tool = tools
.iter()
.find(|t| t.name == "context")
.expect("`context` advertised");
let props = tool
.input_schema
.get("properties")
.and_then(serde_json::Value::as_object)
.expect("`context` declares properties");
assert!(
props.get("limit").is_none(),
"`context` must not advertise a `limit` it does not honour: {props:?}",
);
assert!(
props.get("refresh").is_none(),
"`context` must never offer `--refresh`: it prunes, and this surface is \
read-only",
);
assert!(props.contains_key("key"), "{props:?}");
let desc = tool.description.as_deref().unwrap_or_default();
assert!(
desc.contains(&format!(
"at most {} edges",
rto_graph::TOOL_CONTEXT_EDGE_CAP
)),
"`context` description must state the cap it enforces: {desc}",
);
for claim in ["BOUNDED", "`truncated` is true", "`omitted`"] {
assert!(
desc.contains(claim),
"`context` must say it reports its truncation (`{claim}`): {desc}",
);
}
}
#[test]
fn get_info_advertises_tools() {
let server = seeded();
let info = server.get_info();
assert_eq!(info.server_info.name, "roteiro");
assert!(info.capabilities.tools.is_some());
}
fn repo_with_node(dir: &std::path::Path, key: &str) {
std::fs::create_dir_all(dir).unwrap();
let status = std::process::Command::new("git")
.args(["-c", "init.defaultBranch=main", "init", "-q"])
.current_dir(dir)
.status()
.expect("run git");
assert!(status.success(), "git init failed in {}", dir.display());
let store_dir = dir.join(".git").join("roteiro");
std::fs::create_dir_all(&store_dir).unwrap();
let mut store = Store::open(&store_dir.join("graph.db")).unwrap();
store
.apply_factset(&FactSet::new().with_node(Node::new(key, NodeKind::Struct, key)))
.unwrap();
}
#[tokio::test]
async fn check_tool_runs_against_a_hosted_projects_own_repository() {
let base = std::env::temp_dir().join(format!("rto-mcp-check-{}", std::process::id()));
std::fs::remove_dir_all(&base).ok();
let dir = base.join("app");
std::fs::create_dir_all(&dir).unwrap();
let git = |args: &[&str]| {
let status = std::process::Command::new("git")
.args([
"-c",
"init.defaultBranch=main",
"-c",
"user.email=t@example.com",
"-c",
"user.name=T",
"-c",
"commit.gpgsign=false",
])
.args(args)
.current_dir(&dir)
.status()
.expect("run git");
assert!(status.success(), "git {args:?}");
};
git(&["init", "-q"]);
std::fs::create_dir_all(dir.join("docs/adr")).unwrap();
std::fs::write(dir.join("a.rs"), "pub struct Store;\n").unwrap();
let adr = |id: &str, target: &str| {
format!(
"---\nadr-id: \"{id}\"\nstatus: Accepted\n---\n\n# ADR-{id}\n\n\
## Design\n\nUses [[{target}]].\n"
)
};
std::fs::write(dir.join("docs/adr/0001.md"), adr("0001", "a.rs#Store")).unwrap();
git(&["add", "-A"]);
git(&["commit", "-q", "-m", "seed"]);
let tree = rto_graph::Repo::discover(&dir)
.unwrap()
.head_tree_id()
.unwrap();
let store_dir = dir.join(".git").join("roteiro");
std::fs::create_dir_all(&store_dir).unwrap();
let mut store = Store::open(&store_dir.join("graph.db")).unwrap();
store
.rebuild(
&FactSet::new()
.with_node(Node::new("file:a.rs", NodeKind::File, "a.rs"))
.with_node(Node::new("sym:rust:a.rs#Store", NodeKind::Struct, "Store")),
Some(&tree),
)
.unwrap();
drop(store);
let ws = Workspace::from_repo_paths([dir.clone()]).unwrap();
let server = GraphServer::new(Arc::new(ws));
let out = server.check(Parameters(CheckArgs::default())).await;
let json: serde_json::Value = serde_json::from_str(&text_of(&out)).expect("json");
assert_eq!(json["gate"], "pass", "{json}");
assert_eq!(json["report"]["adrs"], 1, "{json}");
assert_eq!(json["report"]["links_ok"], 1, "{json}");
assert_eq!(json["checked_against"]["source"], "committed");
assert_eq!(json["checked_against"]["tree"], tree);
assert!(json.get("not_run_reason").is_none(), "{json}");
std::fs::write(dir.join("docs/adr/0001.md"), adr("0001", "a.rs#Ghost")).unwrap();
git(&["add", "-A"]);
git(&["commit", "-q", "-m", "drift"]);
let tree = rto_graph::Repo::discover(&dir)
.unwrap()
.head_tree_id()
.unwrap();
let mut store = Store::open(&store_dir.join("graph.db")).unwrap();
store
.rebuild(
&FactSet::new()
.with_node(Node::new("file:a.rs", NodeKind::File, "a.rs"))
.with_node(Node::new("sym:rust:a.rs#Store", NodeKind::Struct, "Store")),
Some(&tree),
)
.unwrap();
drop(store);
let ws = Workspace::from_repo_paths([dir.clone()]).unwrap();
let out = GraphServer::new(Arc::new(ws))
.check(Parameters(CheckArgs::default()))
.await;
let json: serde_json::Value = serde_json::from_str(&text_of(&out)).expect("json");
assert_eq!(json["gate"], "fail", "{json}");
assert_eq!(
json["report"]["violations"][0]["kind"], "broken-link",
"{json}"
);
assert!(
json["report"]["violations"][0]["message"]
.as_str()
.is_some_and(|m| m.contains("Ghost")),
"{json}"
);
std::fs::remove_dir_all(&base).ok();
}
#[tokio::test]
async fn explain_follows_a_project_qualified_key() {
let base = std::env::temp_dir().join(format!("rto-mcp-xrepo-{}", std::process::id()));
std::fs::remove_dir_all(&base).ok();
repo_with_node(&base.join("app"), "sym:rust:a.rs#OnlyInApp");
repo_with_node(&base.join("deploy"), "sym:rust:b.rs#OnlyInDeploy");
let ws = Workspace::from_repo_paths([base.join("app"), base.join("deploy")]).unwrap();
let server = GraphServer::new(Arc::new(ws));
let out = server
.explain(Parameters(ExplainArgs {
key: "app::sym:rust:a.rs#OnlyInApp".into(),
project: Some("deploy".into()),
}))
.await;
let json: serde_json::Value = serde_json::from_str(&text_of(&out)).expect("json");
assert_eq!(json["node"]["key"], "sym:rust:a.rs#OnlyInApp");
let out = server
.explain(Parameters(ExplainArgs {
key: "sym:rust:b.rs#OnlyInDeploy".into(),
project: Some("deploy".into()),
}))
.await;
let json: serde_json::Value = serde_json::from_str(&text_of(&out)).expect("json");
assert_eq!(json["node"]["key"], "sym:rust:b.rs#OnlyInDeploy");
std::fs::remove_dir_all(&base).ok();
}
}