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>,
}
#[cfg(feature = "execution")]
#[derive(Debug, Default, Deserialize, JsonSchema)]
struct SecurityListArgs {
#[serde(default)]
analyzer: Option<String>,
#[serde(default)]
#[schemars(range(min = 1, max = 100))]
limit: Option<u32>,
#[serde(default)]
project: Option<String>,
}
#[cfg(feature = "execution")]
#[derive(Debug, Default, Deserialize, JsonSchema)]
struct SecurityStatusArgs {
#[serde(default)]
analyzer: Option<String>,
#[serde(default)]
project: Option<String>,
}
#[cfg(feature = "execution")]
#[derive(Debug, Default, Deserialize, JsonSchema)]
struct SandboxStatusArgs {}
#[cfg(feature = "execution")]
#[derive(Debug, Default, Deserialize, JsonSchema)]
struct SandboxClearArgs {
#[serde(default)]
image: Option<String>,
#[serde(default)]
everything: Option<bool>,
#[serde(default)]
dry_run: Option<bool>,
}
#[cfg(feature = "execution")]
fn checked_analyzer(given: Option<&str>) -> Result<Option<&str>, String> {
match given {
None => Ok(None),
Some(name) if rto_exec::known_analyzers().contains(&name) => Ok(Some(name)),
Some(name) => Err(format!(
"unknown analyzer `{name}` (expected {})",
rto_exec::known_analyzers().join("|")
)),
}
}
#[derive(Clone)]
struct GraphServer {
workspace: SharedWorkspace,
#[cfg(feature = "execution")]
asset_root: std::path::PathBuf,
#[allow(dead_code)]
tool_router: ToolRouter<Self>,
}
impl GraphServer {
fn new(workspace: SharedWorkspace) -> Self {
Self {
workspace,
#[cfg(feature = "execution")]
asset_root: rto_exec::asset_root(),
tool_router: Self::routes(),
}
}
#[cfg(all(test, feature = "execution"))]
fn with_asset_root(mut self, root: std::path::PathBuf) -> Self {
self.asset_root = root;
self
}
fn routes() -> ToolRouter<Self> {
let routes = Self::tool_router();
#[cfg(feature = "execution")]
let routes = routes + Self::security_tool_router();
#[cfg(feature = "execution")]
let routes = routes + Self::sandbox_tool_router();
routes
}
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() }))
}
}
#[cfg(feature = "execution")]
#[tool_router(router = security_tool_router)]
impl GraphServer {
#[tool(
description = "List the SECURITY FINDINGS stored for this repository — every live \
findings layer with the run evidence behind it (analyzer, version, \
backend, isolation, advisory database, report digest) and a page of \
its findings. \
READ `coverage` FIRST. It is `analyzed` or \
`no-analyzer-on-record`, and the second is a real outcome that is \
NOT a clean repository: it means no analyzer result is on record \
here. A `no-analyzer-on-record` result carries NO `report` at all — \
so if you are looking for `findings` and there is no `report`, \
nothing was checked and you must say so rather than report zero \
findings. An analyzer that ran and found nothing is the OTHER \
case: `coverage` is `analyzed` and `findings` is 0. \
BOUNDED, and it tells you when it bound something. `limit` \
(1-100, default 20 — no unlimited setting) is findings PER LAYER; \
each layer carries its true `findings` count, the `page` actually \
returned, `truncated`, and how many were `omitted`. A page keeps \
the most severe findings first, so what is omitted is the least \
severe — never conclude a severity is absent from a truncated \
page. \
`cross_reference` is a VIEW over those findings, not a \
replacement: it groups dependency advisories both analyzers \
reported, `confirmed_by` says how many said so, `1` is a normal \
state rather than a discrepancy, and the `findings` total above is \
unchanged by it. Every finding named there is still addressable \
under its own key. \
This is read-only: it cannot run an analyzer, and it cannot \
ingest a report. Ask the user to run `roteiro security run` or \
`roteiro security ingest` — a tool call is not a person consenting \
to execution."
)]
async fn security_list(
&self,
Parameters(args): Parameters<SecurityListArgs>,
) -> CallToolResult {
let analyzer = match checked_analyzer(args.analyzer.as_deref()) {
Ok(analyzer) => analyzer,
Err(e) => return tool_error(&e),
};
let limit = model_limit(args.limit, 20, 100);
query_result(self.with_project(args.project.as_deref(), |store| {
store
.findings_layers(analyzer)
.map(|layers| rto_exec::security_list(layers, limit))
}))
}
#[tool(
description = "Report SECURITY READINESS in TWO SEPARATELY SCOPED SECTIONS, and \
the distinction is the whole point of the tool — do not merge \
them when you report it. \
`machine` (scope `machine`) describes THIS HOST: the pinned-asset \
cache under `asset_root`, and each shipped analyzer's coverage \
matrix with its `host_readiness`. It says nothing whatsoever about \
whether anything has been run, and it is identical for every \
project this server hosts. \
`host_readiness` is THREE states, not a boolean, because the fix \
differs and only one of them is Roteiro's to perform. `ready` = \
assets provisioned AND the analyzer's program on PATH. \
`assets-not-provisioned` = ask the user to run `roteiro security \
prefetch`. `binary-not-found` = `missing_programs` names what is \
absent, and ROTEIRO NEVER INSTALLS ANALYZERS — ask the user to \
install it, or to produce a report elsewhere and `roteiro \
security ingest` it. Both underlying facts (`assets_provisioned`, \
`missing_programs`) are ALWAYS present, so when the state is not \
`ready` read both before telling the user what to do: a host can \
be missing an asset AND a binary, and `host_readiness` names only \
the first remedy. \
Do not read `ready` as more than it says — it is readiness to run \
ON THIS HOST. The sandboxed backend supplies the analyzer from a \
digest-pinned image, so `binary-not-found` does not block it, and \
this tool does not inspect the image store, so it reports no \
sandbox verdict at all. \
`repository` (scope `repository`) describes ONE PROJECT — the one \
named in its own `project` field, which the `project` argument \
selects: which findings layers are live, how many findings each \
holds, and how old the advisory database behind each one is. \
`possibly_stale` is `true` whenever an advisory database is \
involved and NEVER means current; `false` means only that the \
result has no advisory-data axis. \
READ `repository.coverage` before concluding anything. It is \
`analyzed` or `no-analyzer-on-record`; the second carries no \
`layers` at all and means nothing has been analyzed in that \
project, which is NOT a clean repository. \
It needs no `limit`: this is one row per shipped analyzer, one \
per pinned asset and one per live layer — COUNTS, NEVER FINDINGS. \
Use `security_list` for the findings themselves. \
This is read-only: it cannot provision an asset. `roteiro \
security prefetch` opens the network under an explicit human \
consent and is not available here — ask the user to run it."
)]
async fn security_status(
&self,
Parameters(args): Parameters<SecurityStatusArgs>,
) -> CallToolResult {
let analyzer = match checked_analyzer(args.analyzer.as_deref()) {
Ok(analyzer) => analyzer,
Err(e) => return tool_error(&e),
};
let project = args.project.as_deref();
let project_name = match self.workspace.resolve(project) {
Ok(name) => name,
Err(e) => return tool_error(&e.to_string()),
};
let root = self.asset_root.clone();
let now = rto_exec::rfc3339_utc(std::time::SystemTime::now());
query_result(self.with_project(project, |store| {
store.findings_layers(analyzer).map(|layers| {
rto_exec::security_status(&root, analyzer, &project_name, &layers, &now)
})
}))
}
}
#[cfg(feature = "execution")]
#[tool_router(router = sandbox_tool_router)]
impl GraphServer {
#[tool(
description = "Report what the SANDBOX IMAGE STORE on THIS MACHINE is holding: one \
row per cached container image, with its reference, its digests, \
how many layers it has, how many of its objects are on disk, and \
its size broken down into layers, extracted trees, the derived \
ext4 disk image and the guest base. \
MACHINE-GLOBAL, and `scope` says so. There is one of these per \
asset root and EVERY repository this server hosts shares it, so \
never attribute a size here to the project you are discussing. It \
takes no `project` argument because it has no per-repository half \
— `security_status` is the tool with two scopes. \
`bytes.total` is what an image references; `bytes.exclusive` is \
what dropping THAT IMAGE ALONE would free. They differ when \
another cached image shares a layer, so quote `exclusive` when \
you tell a user what clearing one image would give back. \
`objects` counts the PULLED content — manifest, config, one per \
distinct layer. The extracted trees and disk images are built on \
first run and are a cache below this cache, so an image that has \
only ever been pulled is complete without them and \
`disk_image_built`/`base_disk_built` say whether it has run. \
`unattributed` is bytes no cached image claims; `preserved` is \
state no pinned digest re-obtains, which `sandbox_clear` will \
never remove. \
Read this BEFORE `sandbox_clear` and show the user the numbers: a \
destructive verb with no way to see what it will destroy is \
invoked blind. Every `reference` here is a value `sandbox_clear` \
accepts as `image`. \
It needs no `limit`: one row per cached image, counts and sizes, \
never findings. Read-only."
)]
async fn sandbox_status(
&self,
Parameters(_args): Parameters<SandboxStatusArgs>,
) -> CallToolResult {
match rto_exec::sandbox_status(&self.asset_root) {
Ok(report) => json_result(&report),
Err(e) => tool_error(&e.to_string()),
}
}
#[tool(
description = "DELETE cached container images from the SANDBOX IMAGE STORE on THIS \
MACHINE, and report what that freed. This is the ONE tool here \
that changes anything, and what makes it admissible is also its \
limit: everything it drops is re-obtainable from a pinned digest, \
so it costs a re-download and NEVER information. It cannot reach a \
findings layer, a memory record or the graph. \
MACHINE-GLOBAL. There is one store per asset root and EVERY \
repository this server hosts shares it, so clearing on behalf of \
one project slows the next sandboxed run for all of them. It takes \
no `project` argument, and `scope` in the result says `machine`. \
TELL THE USER FIRST. Call `sandbox_status` and show them what is \
cached and what it is costing; a re-pull is minutes to tens of \
minutes and several gigabytes of download. \
`image` and `everything` are DIFFERENT REQUESTS and neither has a \
default: pass `image` with a reference from `sandbox_status`, or \
`everything: true`. Supplying neither is an ERROR and does not \
mean everything; supplying both is an error too. `dry_run: true` \
reports what would go and removes nothing — the result's `applied` \
field says which happened. \
REPORT WHAT IT FREED. `freed_bytes` is the accounting, \
`store_bytes_before`/`store_bytes_after` are the store measured \
either side, and they agree to within the index itself. Quote a \
figure rather than saying it worked. \
`retained` is every surviving image re-checked against the disk \
AFTER the deletion, with `complete` per image. If any `complete` \
is false, SAY SO PROMINENTLY — that is a damaged store, not a \
successful clear, and `roteiro security prefetch` is the repair. \
It refuses rather than guessing: a registered box, an entry under \
the store root it does not recognise, or an index row pointing \
outside that root all stop it with nothing removed."
)]
async fn sandbox_clear(
&self,
Parameters(args): Parameters<SandboxClearArgs>,
) -> CallToolResult {
let scope = match (args.image, args.everything.unwrap_or(false)) {
(Some(_), true) => {
return tool_error(
"`image` and `everything` are different requests; pass exactly one.",
);
}
(None, true) => rto_exec::Scope::Everything,
(Some(reference), false) => rto_exec::Scope::Image(reference),
(None, false) => {
return tool_error(
"nothing was named to drop. Pass `image` with a reference from \
`sandbox_status`, or `everything: true`. Supplying neither does not \
mean everything.",
);
}
};
let outcome = if args.dry_run.unwrap_or(false) {
rto_exec::sandbox_plan(&self.asset_root, &scope).map(|(report, _doomed)| report)
} else {
rto_exec::sandbox_clear(&self.asset_root, &scope)
};
match outcome {
Ok(report) => json_result(&report),
Err(e) => tool_error(&e.to_string()),
}
}
}
#[tool_handler(router = Self::routes())]
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"));
let mut instructions = String::from(
"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).",
);
#[cfg(feature = "execution")]
instructions.push_str(
" `sandbox_status` reports what the MACHINE-GLOBAL container-image cache \
is holding and what it costs; `sandbox_clear` deletes from it and is the \
ONE tool here that changes anything — everything it drops is re-obtainable \
from a pinned digest, so it costs a re-download and never information. Show \
the user `sandbox_status` before calling it, quote the bytes it reports \
freeing, and pass exactly one of `image` and `everything` — neither has a \
default and supplying neither is an error rather than a request to clear \
everything.",
);
#[cfg(feature = "execution")]
instructions.push_str(
" `security_list` lists stored analyzer findings and `security_status` \
reports readiness in two separately scoped halves (`machine` = what this \
host has provisioned AND installed, `repository` = one project's \
layers) — read \
`coverage` on both before concluding anything, because \
`no-analyzer-on-record` means nothing has been analyzed and is not a \
clean repository. Neither can run an analyzer, ingest a report or \
prefetch an asset; ask the user to run those.",
);
#[cfg(feature = "execution")]
instructions.push_str(
" Every tool here answers from the graph and none of them changes it, with \
exactly one exception: `sandbox_clear` deletes cached container images — \
bytes a pinned digest re-obtains — and changes nothing the graph says.",
);
#[cfg(not(feature = "execution"))]
instructions.push_str(" Every tool here is read-only.");
instructions.push_str(
" 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.",
);
info.instructions = Some(instructions);
info
}
}
#[must_use]
pub fn tool_names() -> Vec<String> {
let mut names: Vec<String> = GraphServer::routes()
.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,
};
#[cfg(feature = "execution")]
use super::{SandboxClearArgs, SecurityListArgs, SecurityStatusArgs};
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() {
#[cfg(feature = "execution")]
const GATED: &[(&str, u64)] = &[("security_list", 100)];
#[cfg(not(feature = "execution"))]
const GATED: &[(&str, u64)] = &[];
let server = seeded();
let tools = server.tool_router.list_all();
let bounded: Vec<(&str, u64)> = [
("search", 25u64),
("debt_density", 100),
("config_secrets", 200),
("coupling", 100),
]
.into_iter()
.chain(GATED.iter().copied())
.collect();
for (name, max) in bounded {
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 the_three_mutating_security_subcommands_are_never_exposed() {
use std::collections::BTreeSet;
let server = seeded();
let security: BTreeSet<String> = server
.tool_router
.list_all()
.into_iter()
.map(|t| t.name.to_string())
.filter(|n| n.starts_with("security"))
.collect();
#[cfg(feature = "execution")]
let allowed: BTreeSet<String> = ["security_list", "security_status"]
.into_iter()
.map(str::to_owned)
.collect();
#[cfg(not(feature = "execution"))]
let allowed: BTreeSet<String> = BTreeSet::new();
assert_eq!(
security, allowed,
"the `security*` tools must be exactly the read-only pair: `ingest` and \
`run` mutate (both of `run`'s backends end in `replace_findings_layer`) \
and `run` executes; `prefetch` opens the network under a human consent. \
All three are permanent refusals, not gaps",
);
for refused in ["ingest", "run", "prefetch"] {
assert!(
!security.iter().any(|n| n.contains(refused)),
"`security {refused}` is a permanent refusal and must never be a \
tool. Found in: {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}",
);
}
}
#[cfg(feature = "execution")]
fn seeded_with_findings() -> GraphServer {
use rto_graph::{
AnalysisRun, CommandPolicy, Finding, FindingKey, Isolation, RunnerKind, Severity,
SourceIdentity,
};
let mut store = Store::open_in_memory().expect("store");
let run = |analyzer: &str| AnalysisRun {
layer: format!("security:{analyzer}:wt"),
analyzer: analyzer.to_owned(),
analyzer_version: "1.0.0".to_owned(),
runner: RunnerKind::Ingested,
isolation: Isolation::Ingested,
image_digest: None,
rules_digest: None,
advisory_db: None,
command_policy: CommandPolicy::default(),
source: SourceIdentity::default(),
started_at: "2026-08-01T00:00:00Z".to_owned(),
ended_at: "2026-08-01T00:00:01Z".to_owned(),
exit_status: 1,
report_digest: "deadbeef".to_owned(),
};
let finding = |analyzer: &str, rule: &str, severity: Severity| Finding {
key: FindingKey::new(analyzer, &[rule, "no-snippet"]).expect("key"),
rule: rule.to_owned(),
severity,
title: format!("{rule} title"),
message: format!("{rule} message"),
path: None,
span: None,
meta: serde_json::Value::Null,
};
store
.replace_findings_layer(
&run("cargo-audit"),
&[
finding("cargo-audit", "RUSTSEC-2024-0001", Severity::Critical),
finding("cargo-audit", "RUSTSEC-2024-0002", Severity::Low),
],
)
.expect("cargo-audit layer");
store
.replace_findings_layer(
&run("semgrep"),
&[finding("semgrep", "rules.taint", Severity::High)],
)
.expect("semgrep layer");
GraphServer::new(Arc::new(Workspace::single("test", store)))
}
#[cfg(feature = "execution")]
#[tokio::test]
async fn security_list_distinguishes_nothing_ran_from_nothing_found() {
let out = text_of(
&seeded()
.security_list(Parameters(SecurityListArgs::default()))
.await,
);
let json: serde_json::Value = serde_json::from_str(&out).expect("json");
assert_eq!(json["coverage"], "no-analyzer-on-record", "{json}");
assert!(json.get("report").is_none(), "{json}");
assert!(!out.contains("\"findings\""), "{out}");
assert!(
json["no_result_reason"]
.as_str()
.expect("reason")
.contains("NOT a clean result"),
"{json}"
);
let out = text_of(
&seeded_with_findings()
.security_list(Parameters(SecurityListArgs::default()))
.await,
);
let json: serde_json::Value = serde_json::from_str(&out).expect("json");
assert_eq!(json["coverage"], "analyzed", "{json}");
assert_eq!(json["report"]["findings"], 3, "{json}");
assert_eq!(json["report"]["truncated"], false, "{json}");
}
#[cfg(feature = "execution")]
#[tokio::test]
async fn security_list_bounds_per_layer_and_reports_what_it_cut() {
let out = text_of(
&seeded_with_findings()
.security_list(Parameters(SecurityListArgs {
limit: Some(1),
..SecurityListArgs::default()
}))
.await,
);
let json: serde_json::Value = serde_json::from_str(&out).expect("json");
let report = &json["report"];
assert_eq!(report["findings"], 3, "the true total survives: {json}");
assert_eq!(report["returned"], 2, "one from each of two layers: {json}");
assert_eq!(report["truncated"], true);
let layers = report["layers"].as_array().expect("layers");
assert_eq!(layers.len(), 2, "no layer is dropped by the bound: {json}");
let audit = layers
.iter()
.find(|l| l["run"]["analyzer"] == "cargo-audit")
.expect("cargo-audit layer");
assert_eq!(audit["findings"], 2, "true per-layer count: {audit}");
assert_eq!(audit["omitted"], 1);
assert_eq!(audit["truncated"], true);
assert_eq!(audit["page"][0]["severity"], "critical", "{audit}");
}
#[test]
fn the_only_mutating_tool_is_the_one_the_adr_admits() {
use std::collections::BTreeSet;
const REMOVES: [&str; 7] = [
"clear", "delete", "remove", "prune", "evict", "purge", "reset",
];
let server = seeded();
let mutating: BTreeSet<String> = server
.tool_router
.list_all()
.into_iter()
.map(|t| t.name.to_string())
.filter(|name| REMOVES.iter().any(|verb| name.contains(verb)))
.collect();
#[cfg(feature = "execution")]
let allowed: BTreeSet<String> = ["sandbox_clear"].into_iter().map(str::to_owned).collect();
#[cfg(not(feature = "execution"))]
let allowed: BTreeSet<String> = BTreeSet::new();
assert_eq!(
mutating, allowed,
"ADR-0014 v1.6 admits exactly one mutating tool, by a rule and not as an \
exception: a tool may drop state re-obtainable from a pinned digest and may \
drop nothing else. A second one is a decision, not a cleanup",
);
}
#[cfg(feature = "execution")]
#[test]
fn the_sandbox_pair_is_offered_together_and_takes_no_project() {
let server = seeded();
let tools = server.tool_router.list_all();
for name in ["sandbox_status", "sandbox_clear"] {
let tool = tools
.iter()
.find(|t| t.name == name)
.unwrap_or_else(|| panic!("`{name}` advertised"));
let props = tool
.input_schema
.get("properties")
.and_then(serde_json::Value::as_object);
assert!(
props.is_none_or(|props| !props.contains_key("project")),
"`{name}` must not offer a `project` selector: the sandbox store is \
machine-global and the argument would imply an answer that changes with \
it. Schema: {:?}",
tool.input_schema,
);
}
}
#[cfg(feature = "execution")]
fn disposable_asset_root(name: &str) -> std::path::PathBuf {
let root =
std::env::temp_dir().join(format!("rto-render-sandbox-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("boxlite-home").join("images").join("layers"))
.expect("a store root");
root
}
#[cfg(feature = "execution")]
#[tokio::test]
async fn sandbox_clear_refuses_a_scope_it_was_not_given() {
let server = seeded().with_asset_root(disposable_asset_root("refusal"));
let neither = server
.sandbox_clear(Parameters(SandboxClearArgs::default()))
.await;
assert_eq!(neither.is_error, Some(true), "{neither:?}");
let message = format!("{:?}", neither.content);
assert!(
message.contains("does not") && message.contains("everything"),
"silence must be refused, and the refusal must say it is not a request to \
clear everything: {message}"
);
let both = server
.sandbox_clear(Parameters(SandboxClearArgs {
image: Some("registry/a:1".to_owned()),
everything: Some(true),
dry_run: None,
}))
.await;
assert_eq!(both.is_error, Some(true), "{both:?}");
}
#[cfg(feature = "execution")]
#[tokio::test]
async fn sandbox_clear_reports_what_it_freed_and_stays_inside_the_root_it_was_given() {
let root = disposable_asset_root("freed");
std::fs::write(
root.join("boxlite-home/images/layers/sha256-spare.tar.gz"),
vec![b'x'; 4096],
)
.expect("a spare blob");
let server = seeded().with_asset_root(root.clone());
let result = server
.sandbox_clear(Parameters(SandboxClearArgs {
image: None,
everything: Some(true),
dry_run: None,
}))
.await;
assert_ne!(result.is_error, Some(true), "{result:?}");
let document: serde_json::Value =
serde_json::from_str(&text_of(&result)).expect("a clear document");
assert_eq!(document["scope"], "machine", "{document}");
assert_eq!(document["requested"], "everything", "{document}");
assert_eq!(document["applied"], true, "{document}");
assert_eq!(document["freed_bytes"], 4096, "{document}");
assert!(
document["store"]
.as_str()
.expect("a store path")
.starts_with(root.to_str().expect("a utf-8 root")),
"the tool cleared a store outside the root it was given: {document}"
);
}
#[cfg(feature = "execution")]
#[test]
fn the_mutating_tool_states_its_obligations_where_a_model_reads_them() {
let server = seeded();
let tools = server.tool_router.list_all();
let tool = tools
.iter()
.find(|t| t.name == "sandbox_clear")
.expect("`sandbox_clear` advertised");
let description = tool.description.as_deref().unwrap_or_default();
for obligation in [
"sandbox_status",
"freed_bytes",
"DIFFERENT REQUESTS",
"MACHINE-GLOBAL",
"re-obtainable",
"retained",
] {
assert!(
description.contains(obligation),
"`sandbox_clear`'s description must carry `{obligation}` — it is the only \
thing a model reads: {description}"
);
}
}
#[cfg(feature = "execution")]
#[tokio::test]
async fn security_status_labels_which_half_describes_what() {
let out = text_of(
&seeded_with_findings()
.security_status(Parameters(SecurityStatusArgs::default()))
.await,
);
let json: serde_json::Value = serde_json::from_str(&out).expect("json");
assert_eq!(json["machine"]["scope"], "machine", "{json}");
assert_eq!(json["repository"]["scope"], "repository", "{json}");
assert!(json["machine"]["asset_root"].is_string(), "{json}");
assert_eq!(
json["repository"]["project"], "test",
"the RESOLVED project, not the omitted argument: {json}"
);
assert!(json["repository"].get("asset_root").is_none(), "{json}");
assert!(json["machine"].get("project").is_none(), "{json}");
let analyzer = &json["machine"]["analyzers"][0];
assert!(analyzer["host_readiness"].is_string(), "{json}");
assert!(analyzer["assets_provisioned"].is_boolean(), "{json}");
assert!(analyzer["host_programs"].is_array(), "{json}");
assert!(analyzer["missing_programs"].is_array(), "{json}");
assert!(analyzer.get("ready").is_none(), "{json}");
assert_eq!(json["repository"]["coverage"], "analyzed", "{json}");
let layers = json["repository"]["layers"].as_array().expect("layers");
assert_eq!(layers.len(), 2, "{json}");
assert!(
layers.iter().all(|l| l.get("page").is_none()),
"a status row must not carry findings: {json}"
);
let out = text_of(
&seeded()
.security_status(Parameters(SecurityStatusArgs::default()))
.await,
);
let json: serde_json::Value = serde_json::from_str(&out).expect("json");
assert_eq!(json["repository"]["coverage"], "no-analyzer-on-record");
assert!(json["repository"].get("layers").is_none(), "{json}");
assert!(json["machine"]["asset_root"].is_string(), "{json}");
}
#[cfg(feature = "execution")]
#[tokio::test]
async fn an_unknown_analyzer_is_an_error_on_both_security_tools() {
let server = seeded_with_findings();
let list = server
.security_list(Parameters(SecurityListArgs {
analyzer: Some("semgrepp".into()),
..SecurityListArgs::default()
}))
.await;
assert_eq!(list.is_error, Some(true), "{list:?}");
assert!(
text_of(&list).contains("unknown analyzer `semgrepp`"),
"{list:?}"
);
let status = server
.security_status(Parameters(SecurityStatusArgs {
analyzer: Some("semgrepp".into()),
..SecurityStatusArgs::default()
}))
.await;
assert_eq!(status.is_error, Some(true), "{status:?}");
assert!(
text_of(&status).contains("unknown analyzer `semgrepp`"),
"{status:?}"
);
let ok = server
.security_list(Parameters(SecurityListArgs {
analyzer: Some("semgrep".into()),
..SecurityListArgs::default()
}))
.await;
assert_ne!(ok.is_error, Some(true), "{ok:?}");
let json: serde_json::Value = serde_json::from_str(&text_of(&ok)).expect("json");
assert_eq!(json["report"]["layers"].as_array().map(Vec::len), Some(1));
assert_eq!(json["report"]["layers"][0]["run"]["analyzer"], "semgrep");
}
#[cfg(feature = "execution")]
#[test]
fn security_list_description_refuses_the_clean_reading() {
let server = seeded();
let tools = server.tool_router.list_all();
let tool = tools
.iter()
.find(|t| t.name == "security_list")
.expect("`security_list` advertised");
let desc = tool.description.as_deref().unwrap_or_default();
for claim in [
"READ `coverage` FIRST",
"NOT a clean repository",
"carries NO `report`",
"rather than report zero findings",
"PER LAYER",
"most severe findings first",
] {
assert!(desc.contains(claim), "missing `{claim}` from: {desc}");
}
}
#[cfg(feature = "execution")]
#[test]
fn security_status_description_separates_its_two_scopes() {
let server = seeded();
let tools = server.tool_router.list_all();
let tool = tools
.iter()
.find(|t| t.name == "security_status")
.expect("`security_status` advertised");
let desc = tool.description.as_deref().unwrap_or_default();
for claim in [
"TWO SEPARATELY SCOPED SECTIONS",
"THIS HOST",
"ONE PROJECT",
"whether anything has been run",
"NEVER means current",
"NOT a clean repository",
] {
assert!(desc.contains(claim), "missing `{claim}` from: {desc}");
}
}
#[cfg(feature = "execution")]
#[test]
fn security_status_description_says_what_ready_has_checked() {
let server = seeded();
let tools = server.tool_router.list_all();
let tool = tools
.iter()
.find(|t| t.name == "security_status")
.expect("`security_status` advertised");
let desc = tool.description.as_deref().unwrap_or_default();
for claim in [
"THREE states",
"assets provisioned AND the analyzer's program on PATH",
"assets-not-provisioned",
"binary-not-found",
"ROTEIRO NEVER INSTALLS ANALYZERS",
"are ALWAYS present",
"ON THIS HOST",
"does not inspect the image store",
] {
assert!(desc.contains(claim), "missing `{claim}` from: {desc}");
}
}
#[cfg(feature = "execution")]
#[test]
fn security_status_states_why_it_needs_no_bound() {
let server = seeded();
let tools = server.tool_router.list_all();
let tool = tools
.iter()
.find(|t| t.name == "security_status")
.expect("`security_status` advertised");
let props = tool
.input_schema
.get("properties")
.and_then(serde_json::Value::as_object)
.expect("`security_status` declares properties");
assert!(
props.get("limit").is_none(),
"`security_status` must not advertise a `limit` it does not honour: \
{props:?}",
);
let desc = tool.description.as_deref().unwrap_or_default();
for claim in ["needs no `limit`", "COUNTS, NEVER FINDINGS"] {
assert!(desc.contains(claim), "missing `{claim}` from: {desc}");
}
}
#[test]
fn the_instructions_announce_the_security_tools_exactly_when_they_exist() {
let info = seeded().get_info();
let instructions = info.instructions.unwrap_or_default();
let announced = instructions.contains("`security_list`");
assert_eq!(
announced,
cfg!(feature = "execution"),
"the instructions must name `security_list` exactly when the build \
offers it: {instructions}",
);
if cfg!(feature = "execution") {
assert!(
instructions.contains("none of them changes it, with exactly one exception")
&& instructions.contains("`sandbox_clear`")
&& instructions.contains("changes nothing the graph says"),
"{instructions}"
);
} else {
assert!(
instructions.contains("Every tool here is read-only"),
"{instructions}"
);
}
}
#[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_the_repository_of_a_hosted_project() {
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();
}
}