use std::collections::BTreeSet;
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)]
#[serde(deny_unknown_fields)]
struct ExplainArgs {
key: String,
#[serde(default)]
project: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct SearchArgs {
query: String,
#[serde(default)]
#[schemars(range(min = 1, max = 25))]
limit: Option<u32>,
#[serde(default)]
project: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ListKindArgs {
kind: String,
#[serde(default)]
project: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct PathArgs {
from: String,
to: String,
#[serde(default)]
project: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ContextArgs {
key: String,
#[serde(default)]
project: Option<String>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct CheckArgs {
#[serde(default)]
project: Option<String>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct DebtArgs {
#[serde(default)]
categories: Vec<String>,
#[serde(default)]
project: Option<String>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct DensityArgs {
#[serde(default)]
categories: 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)]
#[serde(deny_unknown_fields)]
struct ConfigSecretArgs {
#[serde(default)]
#[schemars(range(min = 1, max = 200))]
limit: Option<u32>,
#[serde(default)]
project: Option<String>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
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)]
#[serde(deny_unknown_fields)]
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)]
#[serde(deny_unknown_fields)]
struct SecurityStatusArgs {
#[serde(default)]
analyzer: Option<String>,
#[serde(default)]
project: Option<String>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ListProjectsArgs {}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ListToolClassesArgs {}
#[cfg(feature = "execution")]
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct SandboxStatusArgs {}
#[cfg(feature = "execution")]
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
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,
tool_router: ToolRouter<Self>,
}
impl GraphServer {
fn new(workspace: SharedWorkspace, advertised: &Advertised) -> Self {
Self {
workspace,
#[cfg(feature = "execution")]
asset_root: rto_exec::asset_root(),
tool_router: Self::routes(advertised),
}
}
#[cfg(all(test, feature = "execution"))]
fn with_asset_root(mut self, root: std::path::PathBuf) -> Self {
self.asset_root = root;
self
}
fn routes(advertised: &Advertised) -> 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();
let mut routes = routes;
for route in routes.map.values_mut() {
if let Some(text) = crate::tool_text::for_tool(&route.attr.name) {
route.attr.description = Some(text.into());
}
}
let Advertised::Only(allowed) = advertised else {
return routes;
};
let mut routes = routes;
for name in routes
.list_all()
.into_iter()
.map(|t| t.name.to_string())
.filter(|name| !allowed.contains(name))
.collect::<Vec<_>>()
{
routes.remove_route(&name);
}
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]
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]
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]
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]
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]
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]
async fn debt(&self, Parameters(args): Parameters<DebtArgs>) -> CallToolResult {
query_result(self.with_project(args.project.as_deref(), |store| {
debt(store, &args.categories, &[])
}))
}
#[tool]
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.categories, &[], order, limit, min_lines)
}))
}
#[tool]
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]
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]
async fn list_projects(
&self,
Parameters(_args): Parameters<ListProjectsArgs>,
) -> CallToolResult {
json_result(&serde_json::json!({ "projects": self.workspace.names() }))
}
#[tool]
async fn list_tool_classes(
&self,
Parameters(_args): Parameters<ListToolClassesArgs>,
) -> CallToolResult {
let in_build: BTreeSet<String> = tool_names().into_iter().collect();
json_result(&crate::tool_class::report(
|name| in_build.contains(name),
|name| self.tool_router.has_route(name),
))
}
}
#[cfg(feature = "execution")]
#[tool_router(router = security_tool_router)]
impl GraphServer {
#[tool]
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]
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]
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]
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()),
}
}
}
fn read_only_rule_clause(has_mutating_tool: bool) -> &'static str {
if has_mutating_tool {
" 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."
} else {
" Every tool here is read-only."
}
}
fn withheld_class_clause(
in_build: impl Fn(&str) -> bool,
has: impl Fn(&str) -> bool,
) -> Option<&'static str> {
let anything_withheld = crate::tool_class::CLASSES
.iter()
.any(|(_, tools)| tools.iter().any(|t| in_build(t) && !has(t)));
(has(crate::tool_class::CLASS_INDEX_TOOL) && anything_withheld).then_some(
" This server was started with only SOME of its tool classes. Before telling a \
user Roteiro cannot do something, call `list_tool_classes`: a class marked \
`not-loaded-here` was left out at startup to save prompt tokens and is not a \
missing capability — name the class so the user can restart the server with it.",
)
}
impl GraphServer {
fn instructions(&self) -> String {
let has = |name: &str| self.tool_router.has_route(name);
let mut out = String::from("Roteiro codebase knowledge graph.");
if has("search") {
out.push_str(
" 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\").",
);
out.push_str(
" Read each hit's `snippet`, or call `explain` on its key, to read \
a node's actual content before describing it — never answer from \
a node's name alone.",
);
}
if has("explain") {
out.push_str(" `explain` gives a key's provenance-labelled neighbourhood.");
}
if has("context") {
out.push_str(" `context` returns that neighbourhood bounded and fingerprinted.");
}
if has("list_kind") {
out.push_str(" `list_kind` enumerates a kind.");
}
if has("path") {
out.push_str(" `path` finds how two nodes connect.");
}
if has("debt") {
out.push_str(" `debt` lists intent-debt markers.");
}
if has("debt_density") {
out.push_str(" `debt_density` ranks files by markers per 1,000 lines.");
}
if has("coupling") {
out.push_str(" `coupling` ranks symbols by directed call fan-in/fan-out.");
}
if has("config_secrets") {
out.push_str(
" `config_secrets` inventories secret-named config keys (an inventory, \
not a secret scan — see its description).",
);
}
if has("check") {
out.push_str(
" `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).",
);
}
if has("sandbox_status") {
out.push_str(
" `sandbox_status` reports what the MACHINE-GLOBAL container-image cache \
is holding and what it costs.",
);
}
if has("sandbox_clear") {
out.push_str(
" `sandbox_clear` deletes from that cache 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.",
);
}
if has("security_list") {
out.push_str(" `security_list` lists stored analyzer findings.");
}
if has("security_status") {
out.push_str(
" `security_status` reports readiness in two separately scoped halves \
(`machine` = what this host has provisioned AND installed, `repository` \
= one project's layers).",
);
}
if has("security_list") || has("security_status") {
out.push_str(
" Read `coverage` before concluding anything from either, 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.",
);
}
out.push_str(read_only_rule_clause(has("sandbox_clear")));
let in_build: BTreeSet<String> = tool_names().into_iter().collect();
if let Some(clause) = withheld_class_clause(|name| in_build.contains(name), has) {
out.push_str(clause);
}
out.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.",
);
out.push_str(
" These tools answer from the committed (`HEAD`) graph this server \
synced, not from anyone's uncommitted working-tree edits — so a count \
here can differ from the same command run in a terminal, which reads \
the working tree by default (its `--committed` flag matches this).",
);
out
}
}
#[allow(unknown_lints, clippy::unused_async_trait_impl)]
#[tool_handler(router = self.tool_router)]
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(self.instructions());
info
}
}
#[must_use]
pub fn tool_argument_names() -> std::collections::BTreeMap<String, BTreeSet<String>> {
GraphServer::routes(&Advertised::All)
.list_all()
.into_iter()
.map(|t| {
let names = t
.input_schema
.get("properties")
.and_then(serde_json::Value::as_object)
.map(|props| props.keys().cloned().collect())
.unwrap_or_default();
(t.name.to_string(), names)
})
.collect()
}
#[must_use]
pub fn tool_descriptions() -> std::collections::BTreeMap<String, String> {
GraphServer::routes(&Advertised::All)
.list_all()
.into_iter()
.map(|t| {
(
t.name.to_string(),
t.description.unwrap_or_default().to_string(),
)
})
.collect()
}
#[must_use]
pub fn tool_names() -> Vec<String> {
advertised_names(&Advertised::All)
}
#[must_use]
pub fn advertised_names(advertised: &Advertised) -> Vec<String> {
let mut names: Vec<String> = GraphServer::routes(advertised)
.list_all()
.into_iter()
.map(|t| t.name.to_string())
.collect();
names.sort();
names
}
#[must_use]
pub fn advertised_bytes(advertised: &Advertised) -> usize {
GraphServer::routes(advertised)
.list_all()
.iter()
.map(|t| {
t.name.len()
+ t.description.as_deref().map_or(0, str::len)
+ serde_json::to_string(&t.input_schema).map_or(0, |s| s.len())
})
.sum()
}
pub const EVERY_TOOL: [&str; 16] = [
"check",
"config_secrets",
"context",
"coupling",
"debt",
"debt_density",
"explain",
"list_kind",
"list_projects",
"list_tool_classes",
"path",
"sandbox_clear",
"sandbox_status",
"search",
"security_list",
"security_status",
];
pub const MUTATING_TOOLS: [&str; 1] = ["sandbox_clear"];
pub const READ_ONLY: &str = "read-only";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum Advertised {
#[default]
All,
Only(BTreeSet<String>),
}
impl Advertised {
#[must_use]
pub fn allows(&self, name: &str) -> bool {
match self {
Self::All => tool_names().iter().any(|t| t == name),
Self::Only(allowed) => allowed.contains(name),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RestrictError {
Unknown(String),
Empty,
}
impl std::fmt::Display for RestrictError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unknown(name) => write!(
f,
"unknown tool `{name}` (expected one of {}, a class ({}), or `{READ_ONLY}`)",
EVERY_TOOL.join(", "),
crate::tool_class::class_names().join(", "),
),
Self::Empty => write!(
f,
"the tool restriction leaves nothing to serve — refusing to start \
rather than advertising the full surface. Name at least one of {}",
tool_names().join(", ")
),
}
}
}
impl std::error::Error for RestrictError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Restriction {
pub advertised: Advertised,
pub absent: Vec<String>,
}
pub fn restrict(names: &[String]) -> Result<Restriction, RestrictError> {
let in_build = tool_names();
let mut allowed = BTreeSet::new();
let mut absent = Vec::new();
for raw in names {
let name = raw.trim();
if name.is_empty() {
continue;
}
if name == READ_ONLY {
allowed.extend(
in_build
.iter()
.filter(|t| !MUTATING_TOOLS.contains(&t.as_str()))
.cloned(),
);
continue;
}
if let Some(tools) = crate::tool_class::tools_in(name) {
allowed.extend(
tools
.iter()
.filter(|t| in_build.iter().any(|b| b == *t))
.map(|t| (*t).to_owned()),
);
continue;
}
if !EVERY_TOOL.contains(&name) {
return Err(RestrictError::Unknown(name.to_owned()));
}
if in_build.iter().any(|t| t == name) {
allowed.insert(name.to_owned());
} else if !absent.iter().any(|a| a == name) {
absent.push(name.to_owned());
}
}
if allowed.is_empty() {
return Err(RestrictError::Empty);
}
allowed.insert(crate::tool_class::CLASS_INDEX_TOOL.to_owned());
Ok(Restriction {
advertised: Advertised::Only(allowed),
absent,
})
}
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>, advertised: &Advertised) -> Result<(), McpError> {
let shared: SharedWorkspace = workspace;
let advertised = advertised.clone();
runtime()?.block_on(async move {
let service = GraphServer::new(shared, &advertised).serve(stdio()).await?;
service.waiting().await?;
Ok(())
})
}
pub fn mcp_router(workspace: Arc<Workspace>, advertised: &Advertised) -> axum::Router {
let shared: SharedWorkspace = workspace;
let advertised = advertised.clone();
let service = StreamableHttpService::new(
move || Ok(GraphServer::new(shared.clone(), &advertised)),
Arc::new(LocalSessionManager::default()),
StreamableHttpServerConfig::default(),
);
axum::Router::new().nest_service("/mcp", service)
}
pub fn serve_http(
workspace: Arc<Workspace>,
addr: SocketAddr,
advertised: &Advertised,
) -> Result<(), McpError> {
let router = mcp_router(workspace, advertised);
runtime()?.block_on(async move {
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, router).await?;
Ok(())
})
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use super::{
Advertised, CheckArgs, ConfigSecretArgs, ContextArgs, CouplingArgs, DebtArgs, DensityArgs,
ExplainArgs, GraphServer, ListKindArgs, PathArgs, SearchArgs, model_limit, tool_names,
};
#[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)), &Advertised::All)
}
#[cfg(feature = "execution")]
fn seeded_with(advertised: &Advertised) -> GraphServer {
GraphServer::new(seeded().workspace, advertised)
}
fn text_of(result: &rmcp::model::CallToolResult) -> String {
result
.content
.iter()
.filter_map(|c| c.as_text().map(|t| t.text.clone()))
.collect()
}
#[test]
fn the_old_argument_name_is_refused_rather_than_silently_dropped() {
let sent = serde_json::json!({ "kind": ["todo"] });
let err = serde_json::from_value::<DebtArgs>(sent).expect_err("refused");
let message = err.to_string();
assert!(
message.contains("unknown field `kind`"),
"the refusal must name the key that was sent: {message}",
);
assert!(
message.contains("`categories`") && message.contains("`project`"),
"and the keys that would have worked — this is the entire migration \
path for a caller written against the old name, so the replacement \
has to be *in the message*: {message}",
);
let err = serde_json::from_value::<DensityArgs>(
serde_json::json!({ "kind": ["todo"], "order": "markers" }),
)
.expect_err("refused");
let message = err.to_string();
assert!(
message.contains("unknown field `kind`") && message.contains("`categories`"),
"{message}",
);
}
#[test]
fn this_surfaces_own_argument_names_still_parse() {
let args: DebtArgs = serde_json::from_value(
serde_json::json!({ "categories": ["todo"], "project": "roteiro" }),
)
.expect("accepted");
assert_eq!(args.categories, ["todo"]);
assert_eq!(args.project.as_deref(), Some("roteiro"));
let bare: DebtArgs = serde_json::from_value(serde_json::json!({})).expect("accepted");
assert!(bare.categories.is_empty() && bare.project.is_none());
}
#[test]
fn every_tool_closes_its_argument_object() {
let open: Vec<String> = GraphServer::routes(&Advertised::All)
.list_all()
.into_iter()
.filter(|t| {
t.input_schema.get("additionalProperties") != Some(&serde_json::Value::Bool(false))
})
.map(|t| t.name.to_string())
.collect();
assert!(
open.is_empty(),
"these tools accept an argument key nobody reads, so a model sending one \
gets an answer to a question it did not ask: {open:?}. Add \
`#[serde(deny_unknown_fields)]` to the argument struct.",
);
}
#[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 {
categories: 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)), &Advertised::All)
}
#[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 an_mcp_client_is_told_to_open_a_hit_rather_than_read_its_name() {
let instructions = seeded().get_info().instructions.unwrap_or_default();
assert!(
instructions.contains("`search`"),
"this fixture must advertise `search`, or the assertion below is \
vacuous: {instructions}",
);
for clause in [
"Read each hit's `snippet`",
"call `explain` on its key",
"never answer from a node's name alone",
] {
assert!(
instructions.contains(clause),
"MCP has no system turn, so `instructions` is the only place this \
surface can carry `{clause}` — #675 moved it here out of \
`tool_text::SEARCH` and it must not go missing: {instructions}",
);
}
}
#[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 every_tool_covers_this_build() {
let in_build: BTreeSet<String> = tool_names().into_iter().collect();
let declared: BTreeSet<String> =
super::EVERY_TOOL.iter().map(|s| (*s).to_owned()).collect();
let missing: Vec<&String> = in_build.difference(&declared).collect();
assert!(
missing.is_empty(),
"`EVERY_TOOL` is missing routes this build offers: {missing:?} — a new \
tool has to be added there or `--tools` will refuse its name as a typo",
);
#[cfg(feature = "execution")]
assert_eq!(
in_build, declared,
"with every feature on, `EVERY_TOOL` must be exactly what is routed",
);
}
#[test]
fn read_only_is_every_tool_but_the_mutating_ones() {
let restriction = super::restrict(&[super::READ_ONLY.to_owned()]).expect("resolves");
let Advertised::Only(allowed) = restriction.advertised else {
panic!("`read-only` must restrict");
};
for name in tool_names() {
assert_eq!(
allowed.contains(&name),
!super::MUTATING_TOOLS.contains(&name.as_str()),
"`read-only` and `MUTATING_TOOLS` disagree about `{name}`",
);
}
let server = GraphServer::new(
Arc::new(rto_graph::Workspace::single(
"test",
rto_graph::Store::open_in_memory().expect("store"),
)),
&Advertised::Only(allowed),
);
let instructions = server.get_info().instructions.unwrap_or_default();
assert!(
instructions.contains("Every tool here is read-only"),
"a read-only surface must claim to be one: {instructions}",
);
}
#[cfg(feature = "execution")]
#[test]
fn a_withheld_tool_is_neither_listed_nor_dispatchable() {
use rmcp::ServerHandler as _;
let restriction =
super::restrict(&["search".to_owned(), "explain".to_owned()]).expect("resolves");
let server = seeded_with(&restriction.advertised);
let listed: BTreeSet<String> = server
.tool_router
.list_all()
.into_iter()
.map(|t| t.name.to_string())
.collect();
assert_eq!(
listed,
["explain", "list_tool_classes", "search"]
.into_iter()
.map(str::to_owned)
.collect::<BTreeSet<String>>(),
);
assert!(
server.get_tool("sandbox_clear").is_none(),
"a withheld tool must not be dispatchable",
);
assert!(
server.get_tool("search").is_some(),
"an allowed tool must be"
);
let instructions = server.get_info().instructions.unwrap_or_default();
assert!(
!instructions.contains("`sandbox_clear`") && instructions.contains("`search`"),
"{instructions}",
);
}
#[test]
fn an_unknown_tool_name_is_refused_rather_than_ignored() {
let err = super::restrict(&["explian".to_owned()]).expect_err("must refuse");
assert_eq!(err, super::RestrictError::Unknown("explian".to_owned()));
assert!(
err.to_string().contains("explain"),
"the refusal must list the real names: {err}",
);
}
#[test]
fn a_restriction_that_resolves_to_nothing_refuses_rather_than_serving_everything() {
for names in [vec![], vec![String::new()], vec![" ".to_owned()]] {
assert_eq!(
super::restrict(&names),
Err(super::RestrictError::Empty),
"{names:?} must not be read as `advertise everything`",
);
}
}
#[cfg(feature = "execution")]
#[test]
fn restricting_the_surface_removes_advertised_bytes() {
let all = super::advertised_bytes(&Advertised::All);
let restriction = super::restrict(&[super::READ_ONLY.to_owned()]).expect("resolves");
let read_only = super::advertised_bytes(&restriction.advertised);
assert!(
read_only < all,
"dropping the mutating tool must drop bytes: {read_only} vs {all}",
);
}
fn wire_needle(text: &str) -> &str {
let stop = text.find(['"', '\\', '\n']).unwrap_or(text.len());
let cap = text
.char_indices()
.nth(60)
.map_or(text.len(), |(byte, _)| byte);
&text[..stop.min(cap)]
}
#[test]
fn a_withheld_class_contributes_no_bytes_to_the_serialized_surface() {
let full = super::tool_descriptions();
for (class, tools) in crate::tool_class::CLASSES {
let others: Vec<String> = crate::tool_class::class_names()
.into_iter()
.filter(|c| *c != class)
.map(str::to_owned)
.collect();
let restriction = super::restrict(&others).expect("three classes resolve");
let listed = GraphServer::routes(&restriction.advertised).list_all();
let names: BTreeSet<String> = listed.iter().map(|t| t.name.to_string()).collect();
let wire = serde_json::to_string(&listed).expect("the JSON a client receives");
for tool in tools {
assert!(
!names.contains(*tool),
"withholding `{class}` left `{tool}` advertised",
);
let Some(text) = full.get(*tool) else {
continue;
};
let needle = wire_needle(text);
assert!(
needle.len() > 20,
"`{tool}`'s needle is too short to prove anything: {needle:?}",
);
assert!(
!wire.contains(needle),
"withholding `{class}` still shipped `{tool}`'s description to the \
client. The route may be gone while its prose is not, and the prose \
is what costs: {needle:?}",
);
}
}
}
#[test]
fn a_loaded_class_advertises_every_tool_it_names() {
let full = super::tool_descriptions();
let in_build: BTreeSet<String> = tool_names().into_iter().collect();
for (class, tools) in crate::tool_class::CLASSES {
if !tools.iter().any(|t| in_build.contains(*t)) {
assert_eq!(
super::restrict(&[class.to_owned()]),
Err(super::RestrictError::Empty),
"`--tools {class}` selects nothing this build carries, and a \
restriction that resolves to nothing must refuse to start rather \
than serve the full surface",
);
continue;
}
let restriction = super::restrict(&[class.to_owned()]).expect("a class resolves");
let listed = GraphServer::routes(&restriction.advertised).list_all();
let described: std::collections::BTreeMap<String, String> = listed
.iter()
.map(|t| {
(
t.name.to_string(),
t.description.as_deref().unwrap_or_default().to_owned(),
)
})
.collect();
for tool in tools.iter().filter(|t| in_build.contains(**t)) {
let got = described.get(*tool).unwrap_or_else(|| {
panic!("`--tools {class}` must advertise its own member `{tool}`")
});
assert_eq!(
Some(got),
full.get(*tool),
"`{tool}` is advertised under `{class}` with a different description \
than an unrestricted server sends",
);
}
}
}
#[test]
fn selecting_every_class_is_the_unrestricted_surface() {
let names: Vec<String> = crate::tool_class::class_names()
.into_iter()
.map(str::to_owned)
.collect();
let restriction = super::restrict(&names).expect("every class resolves");
assert_eq!(
GraphServer::routes(&restriction.advertised)
.list_all()
.into_iter()
.map(|t| t.name.to_string())
.collect::<BTreeSet<String>>(),
tool_names().into_iter().collect::<BTreeSet<String>>(),
"every class together must be the whole surface — a tool in no class \
would be silently unreachable through `--tools`",
);
assert_eq!(
super::advertised_bytes(&restriction.advertised),
super::advertised_bytes(&Advertised::All),
"and cost exactly the same bytes",
);
}
#[test]
fn every_tool_has_exactly_one_class() {
for name in tool_names() {
if name == crate::tool_class::CLASS_INDEX_TOOL {
assert!(
crate::tool_class::class_of(&name).is_none(),
"the class index must belong to no class",
);
continue;
}
assert!(
crate::tool_class::class_of(&name).is_some(),
"`{name}` is routed but is in no class — `--tools <class>` cannot \
reach it and `list_tool_classes` will not mention it",
);
}
}
#[cfg(feature = "execution")]
#[test]
fn the_class_index_is_advertised_whatever_was_withheld() {
use rmcp::ServerHandler as _;
for names in [
vec!["query".to_owned()],
vec!["search".to_owned()],
vec![super::READ_ONLY.to_owned()],
vec!["security".to_owned(), "sandbox".to_owned()],
] {
let restriction = super::restrict(&names).expect("resolves");
let server = seeded_with(&restriction.advertised);
assert!(
server
.tool_router
.list_all()
.iter()
.any(|t| t.name == crate::tool_class::CLASS_INDEX_TOOL),
"`--tools {names:?}` withheld the class index",
);
assert!(
server
.get_tool(crate::tool_class::CLASS_INDEX_TOOL)
.is_some(),
"listed but not dispatchable — advertisement is not authority, and \
neither is its absence",
);
}
}
#[cfg(feature = "execution")]
#[test]
fn only_a_restricted_server_points_a_model_at_the_class_index() {
use rmcp::ServerHandler as _;
let restriction = super::restrict(&["query".to_owned()]).expect("resolves");
let narrow = seeded_with(&restriction.advertised)
.get_info()
.instructions
.unwrap_or_default();
assert!(
narrow.contains("list_tool_classes") && narrow.contains("not a missing capability"),
"a restricted server must say a class was withheld: {narrow}",
);
let full = seeded().get_info().instructions.unwrap_or_default();
assert!(
!full.contains("not a missing capability"),
"an unrestricted server has nothing to recover, and the sentence costs \
tokens on every turn: {full}",
);
}
#[test]
fn no_feature_set_makes_an_unrestricted_server_claim_a_restriction() {
use rmcp::ServerHandler as _;
let instructions = seeded().get_info().instructions.unwrap_or_default();
assert!(
!instructions.contains("not a missing capability"),
"an unrestricted server withheld nothing and must not say it did — the \
`security_*`/`sandbox_*` tools are absent from an `execution`-less build \
for a reason no `--tools` flag can undo, and calling that a withholding \
both misinforms the model and spends tokens on every turn: {instructions}",
);
}
#[test]
fn an_unrestricted_server_never_claims_a_class_was_withheld() {
let index = crate::tool_class::CLASS_INDEX_TOOL;
let execution_tool = |name: &str| {
crate::tool_class::class_of(name).is_some_and(|c| c == "security" || c == "sandbox")
};
assert_eq!(
super::withheld_class_clause(|_| true, |_| true),
None,
"a full server has no withheld class and must not spend tokens saying so",
);
let carried = |name: &str| !execution_tool(name);
assert_eq!(
super::withheld_class_clause(carried, carried),
None,
"a build that does not carry the `execution` tools withheld nothing — \
saying otherwise tells a model a restriction was applied that was not, \
and costs tokens on every turn to do it",
);
let kept = |name: &str| {
name == index || crate::tool_class::class_of(name).is_some_and(|c| c == "query")
};
assert!(
super::withheld_class_clause(|_| true, kept).is_some(),
"a genuinely restricted server must still point a model at the index",
);
assert!(
super::withheld_class_clause(carried, |n| carried(n) && kept(n)).is_some(),
"a restriction and a feature gate can coexist, and the restriction is \
still worth telling a model about",
);
let no_index = |name: &str| {
crate::tool_class::class_of(name).is_some_and(|c| c == "query") && name != index
};
assert!(
crate::tool_class::CLASSES
.iter()
.any(|(_, tools)| tools.iter().any(|t| !no_index(t))),
"the fixture must actually withhold something, or the next assertion \
passes for the wrong reason",
);
assert_eq!(super::withheld_class_clause(|_| true, no_index), None);
}
#[cfg(feature = "execution")]
#[test]
fn withholding_security_and_sandbox_removes_a_third_of_the_surface() {
let all = super::advertised_bytes(&Advertised::All);
let restriction = super::restrict(&["query".to_owned(), "quality".to_owned()])
.expect("two classes resolve");
let narrowed = super::advertised_bytes(&restriction.advertised);
assert!(
narrowed * 10 <= all * 7,
"`--tools query,quality` must remove at least 30% of the advertised \
bytes; it removed {} of {all}",
all - narrowed,
);
}
#[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), &Advertised::All);
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), &Advertised::All)
.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), &Advertised::All);
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();
}
}