use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context, Result};
use chrono::Utc;
use rmcp::{
ErrorData as McpError,
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::*,
tool, tool_handler, tool_router,
ServerHandler, ServiceExt,
transport::stdio,
};
use tokio::sync::Mutex;
use nornir::bench;
use nornir::config::{self, Loaded};
use nornir::funnel::{
event::{Event as FunnelEvent, NodeStatus, PlanStatus},
ids::{IdeaId, NodeId, PlanId},
store::Store as FunnelStore,
topo::topo_ready,
};
use nornir::change;
use nornir::guard;
use nornir::index;
use nornir::introspect;
use nornir::release;
use nornir::warehouse::dep_graph::WorkspaceGraph;
use nornir::warehouse::iceberg::IcebergWarehouse;
use nornir::workspace::descriptor::WorkspaceDescriptor;
use serde_json::json;
#[derive(Clone)]
struct NornirServer {
state: Arc<Mutex<State>>,
#[allow(dead_code)] tool_router: ToolRouter<NornirServer>,
}
struct State {
loaded: Loaded,
funnel: FunnelStore,
mimir: Option<Arc<MimirCtx>>,
#[cfg(any(feature = "embed-tract", feature = "embed-ort"))]
embedder: Option<Arc<dyn nornir::vector::store::Embedder>>,
}
struct MimirCtx {
graph: WorkspaceGraph,
workspace_name: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
#[allow(dead_code)] struct VectorSearchArgs {
query: String,
repo: String,
#[serde(default)]
sha: String,
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct RepoArg {
repo: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct RegressionTraceArgs {
repo: String,
#[serde(default)]
workspace: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct SearchArgs {
query: String,
#[serde(default)]
corpus: Option<String>,
#[serde(default)]
repo: Option<String>,
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct SymbolLookupArgs {
binary: String,
pattern: String,
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct DefinedInArgs {
binary: String,
file: String,
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct CallQueryArgs {
binary: String,
name: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct PathBetweenArgs {
binary: String,
from: String,
to: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct KnowledgeSymbolArgs {
repo: String,
arg: String,
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct KnowledgeCallArgs {
repo: String,
name: String,
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct KnowledgeCallPathArgs {
repo: String,
from: String,
to: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct DocsHistoryArgs {
repo: String,
#[serde(default)]
doc: Option<String>,
#[serde(default)]
version: Option<String>,
#[serde(default)]
format: Option<String>,
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct DocsBookArgs {
repo: String,
#[serde(default)]
format: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct FunnelSubmitIdeaArgs {
text: String,
#[serde(default)]
source: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct FunnelCreatePlanArgs {
idea_id: String,
summary: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct FunnelAddNodeArgs {
plan_id: String,
kind: String,
#[serde(default)]
title: Option<String>,
#[serde(default)]
prompt: Option<String>,
#[serde(default)]
targets: Vec<String>,
#[serde(default)]
needs: Vec<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct FunnelLinkArgs {
plan_id: String,
from: String,
to: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct FunnelStatusArgs {
plan_id: String,
node_id: String,
status: String,
#[serde(default)]
why: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct DepsOfArgs {
repo: String,
#[serde(default)]
transitive: bool,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct AffectedArgs {
repos: Vec<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct DepPathArgs {
from: String,
to: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct ExternalCrateArgs {
#[serde(rename = "crate")]
krate: String,
}
#[tool_router]
impl NornirServer {
async fn new(loaded: Loaded) -> Result<Self> {
let funnel_root = std::env::var_os("NORNIR_FUNNEL_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|| FunnelStore::default_root(&loaded.workspace_root));
let funnel = FunnelStore::open_async(&funnel_root)
.await
.with_context(|| format!("open funnel warehouse at {}", funnel_root.display()))?;
eprintln!(
"funnel: {} ideas, {} plans loaded from {}",
funnel.funnel.ideas.len(),
funnel.funnel.plans.len(),
funnel_root.display(),
);
Ok(Self {
state: Arc::new(Mutex::new(State {
loaded,
funnel,
mimir: None,
#[cfg(any(feature = "embed-tract", feature = "embed-ort"))]
embedder: None,
})),
tool_router: Self::tool_router(),
})
}
#[tool(description = "List repos declared in nornir.toml.")]
async fn repos_list(&self) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let names: Vec<String> = s.loaded.nornir.repo.keys().cloned().collect();
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string_pretty(&names).unwrap_or_default(),
)]))
}
#[tool(
description = "Dependency MÃmir: cross-repo dependencies of <repo> (repos it depends on). \
transitive=false (default) returns direct edges with the crate names that justify \
each (`via`); transitive=true returns the full forward closure as repo names. \
Lets a model ask 'what does X build on?' without reasoning over the whole graph."
)]
async fn deps_of(
&self,
Parameters(args): Parameters<DepsOfArgs>,
) -> Result<CallToolResult, McpError> {
let mimir = self.mimir().await?;
let g = &mimir.graph;
ensure_repo(g, &args.repo)?;
let value = if args.transitive {
json!({
"repo": args.repo,
"transitive": true,
"dependencies": g.deps_transitive(&args.repo).into_iter().collect::<Vec<_>>(),
})
} else {
let direct: Vec<_> = g
.dependencies_of(&args.repo)
.into_iter()
.map(|e| json!({ "repo": e.to, "via": e.via.iter().cloned().collect::<Vec<_>>() }))
.collect();
json!({ "repo": args.repo, "transitive": false, "dependencies": direct })
};
ok_json(&value)
}
#[tool(
description = "Dependency MÃmir: cross-repo dependents of <repo> (repos that depend ON it) — \
the BLAST RADIUS of changing <repo>. transitive=false (default) returns direct \
dependents with the `via` crates; transitive=true returns the full reverse closure. \
Headline tool for 'if I touch X, what must I re-validate?'."
)]
async fn dependents_of(
&self,
Parameters(args): Parameters<DepsOfArgs>,
) -> Result<CallToolResult, McpError> {
let mimir = self.mimir().await?;
let g = &mimir.graph;
ensure_repo(g, &args.repo)?;
let value = if args.transitive {
json!({
"repo": args.repo,
"transitive": true,
"dependents": g.dependents_transitive(&args.repo).into_iter().collect::<Vec<_>>(),
})
} else {
let direct: Vec<_> = g
.dependents_of(&args.repo)
.into_iter()
.map(|e| json!({ "repo": e.from, "via": e.via.iter().cloned().collect::<Vec<_>>() }))
.collect();
json!({ "repo": args.repo, "transitive": false, "dependents": direct })
};
ok_json(&value)
}
#[tool(
description = "Dependency MÃmir: given a set of changed repos, return the invalidation set — \
the changed repos plus everything that transitively depends on them — in build \
order (dependencies first). This is exactly what a release/bench run must re-check."
)]
async fn affected_by_change(
&self,
Parameters(args): Parameters<AffectedArgs>,
) -> Result<CallToolResult, McpError> {
let mimir = self.mimir().await?;
let g = &mimir.graph;
for r in &args.repos {
ensure_repo(g, r)?;
}
let affected = g.affected_by_change(&args.repos);
ok_json(&json!({ "changed": args.repos, "affected": affected }))
}
#[tool(
description = "Dependency MÃmir: full workspace build order (dependencies before dependents). \
Errors if the cross-repo graph contains a cycle."
)]
async fn build_order(&self) -> Result<CallToolResult, McpError> {
let mimir = self.mimir().await?;
let order = mimir.graph.build_order().map_err(internal)?;
ok_json(&json!({ "build_order": order }))
}
#[tool(
description = "Dependency MÃmir: shortest dependency path from <from> to <to> (following \
dependency edges), annotated with the crate names (`via`) that justify each hop. \
Answers 'why does from depend on to?'. Returns null path if to is not reachable."
)]
async fn dep_path(
&self,
Parameters(args): Parameters<DepPathArgs>,
) -> Result<CallToolResult, McpError> {
let mimir = self.mimir().await?;
let g = &mimir.graph;
ensure_repo(g, &args.from)?;
ensure_repo(g, &args.to)?;
match g.dep_path(&args.from, &args.to) {
Some(path) => {
let hops: Vec<_> = path
.windows(2)
.map(|w| {
let via: Vec<String> = g
.dependencies_of(&w[0])
.into_iter()
.find(|e| e.to == w[1])
.map(|e| e.via.iter().cloned().collect())
.unwrap_or_default();
json!({ "from": w[0], "to": w[1], "via": via })
})
.collect();
ok_json(&json!({ "from": args.from, "to": args.to, "path": path, "hops": hops }))
}
None => ok_json(&json!({ "from": args.from, "to": args.to, "path": null, "hops": [] })),
}
}
#[tool(
description = "Dependency MÃmir: workspace repos that consume external crate <crate> \
(crates not produced by any repo in the workspace). Answers 'who uses serde?'."
)]
async fn external_dep_users(
&self,
Parameters(args): Parameters<ExternalCrateArgs>,
) -> Result<CallToolResult, McpError> {
let mimir = self.mimir().await?;
let users = mimir.graph.external_dep_users(&args.krate);
ok_json(&json!({ "crate": args.krate, "users": users }))
}
#[tool(
description = "Dependency MÃmir: render the cross-repo dependency graph as a Mermaid \
flowchart (edges labelled with the justifying crate names). Useful for a \
human/agent to visualise the whole workspace at once."
)]
async fn dep_graph_mermaid(&self) -> Result<CallToolResult, McpError> {
let mimir = self.mimir().await?;
Ok(CallToolResult::success(vec![Content::text(graph_mermaid(&mimir.graph))]))
}
#[tool(
description = "Dependency MÃmir (Urðr↔Verðandi diff): compare each repo's current HEAD against \
the SHA nornir last recorded as released, then expand the moved repos through the \
dependency graph into the build-ordered re-run set. One call answers 'what moved \
and what must I therefore re-validate?'. All git reads are in-process (gix)."
)]
async fn changed_since_last_release(&self) -> Result<CallToolResult, McpError> {
let mimir = self.mimir().await?;
let warehouse_root = {
let s = self.state.lock().await;
s.loaded.warehouse_root()
};
let wh = IcebergWarehouse::open(&warehouse_root)
.with_context(|| format!("open warehouse at {}", warehouse_root.display()))
.map_err(internal)?;
let change = change::detect(&wh, &mimir.graph, &mimir.workspace_name)
.await
.map_err(internal)?;
ok_json(&change)
}
#[tool(description = "Regression time-bisect (deterministic, no AI): for <repo>, scan the \
recorded release history and return the last GREEN release, the first RED one (the gate's \
last-good → first-bad boundary), the suspect commit range, and the full oldest→newest \
timeline. Reuses release_lineage; the backward-looking inverse of the no_regression gate. \
Optional `workspace` restricts the scan (empty = every workspace).")]
async fn regression_trace(
&self,
Parameters(args): Parameters<RegressionTraceArgs>,
) -> Result<CallToolResult, McpError> {
let mimir = self.mimir().await.ok();
let warehouse_root = {
let s = self.state.lock().await;
s.loaded.warehouse_root()
};
let wh = IcebergWarehouse::open(&warehouse_root)
.with_context(|| format!("open warehouse at {}", warehouse_root.display()))
.map_err(internal)?;
let graph = mimir.as_ref().map(|m| &m.graph);
let trace =
nornir::release::regression::trace_gate_async(&wh, &args.workspace, &args.repo, graph)
.await
.map_err(internal)?;
ok_json(&trace)
}
#[tool(
description = "Semantic (vector) code search. Embeds the query with jina-v2-base-code and \
searches the repo's materialized embeddings in the warehouse — works at any historical \
git SHA (time-travel) with no re-embed or git walk. The repo must have been vectorized \
first via `nornir vector index <repo>`. Returns ranked {file, span, score} hits. \
(Requires the server built with `--features embed-tract` or `embed-ort`.)"
)]
async fn vector_search(
&self,
Parameters(args): Parameters<VectorSearchArgs>,
) -> Result<CallToolResult, McpError> {
#[cfg(any(feature = "embed-tract", feature = "embed-ort"))]
{
let embedder = self.embedder().await?;
let warehouse_root = {
let s = self.state.lock().await;
s.loaded.warehouse_root()
};
let repo = args.repo.clone();
let query = args.query.clone();
let sha = args.sha.clone();
let limit = args.limit.unwrap_or(10);
let hits = tokio::task::spawn_blocking(move || -> anyhow::Result<_> {
let mp = embedder.profile().id();
let q = embedder.embed(std::slice::from_ref(&query))?;
let wh = IcebergWarehouse::open(&warehouse_root).with_context(|| {
format!("open warehouse at {}", warehouse_root.display())
})?;
let sha = (!sha.is_empty()).then_some(sha.as_str());
nornir::vector::store::search(&wh, &repo, sha, &mp, &q[0], limit)
})
.await
.map_err(internal)?
.map_err(internal)?;
let out: Vec<_> = hits
.iter()
.map(|(score, o)| {
json!({
"score": score,
"file": o.file,
"start_line": o.start_line,
"end_line": o.end_line,
})
})
.collect();
ok_json(&json!({ "repo": args.repo, "hits": out }))
}
#[cfg(not(any(feature = "embed-tract", feature = "embed-ort")))]
{
let _ = args;
Err(internal(anyhow::anyhow!(
"this nornir-mcp was built without an embedder: rebuild with \
`--features mcp,embed-tract` (CPU) or `--features mcp,embed-ort` (GPU)"
)))
}
}
#[tool(description = "Guard: report writable state of every [guard].forbidden path.")]
async fn guard_status(&self) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let report = guard::status(&s.loaded.workspace_root, &s.loaded.nornir.guard.forbidden);
Ok(CallToolResult::success(vec![Content::text(format_status(&report))]))
}
#[tool(description = "Guard: chmod -w every [guard].forbidden path that exists, then record a \
tamper-evidence manifest (sha256 + mode per path) and export guard-policy.json. \
The manifest makes later drift non-deniable; verify it with guard_verify.")]
async fn guard_apply(&self) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let report = guard::apply_and_record(&s.loaded.workspace_root, &s.loaded.nornir.guard.forbidden)
.map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(format_status(&report))]))
}
#[tool(description = "Guard: verify every [guard].forbidden path against the manifest recorded by \
guard_apply. Reports per-path drift (vanished/appeared/mode/content). This is the \
tamper-evidence read; it never modifies the tree. Run guard_apply first to seed the manifest.")]
async fn guard_verify(&self) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let recorded = guard::read_manifest(&s.loaded.workspace_root).map_err(internal)?;
let report = guard::verify(&s.loaded.workspace_root, &recorded);
let intact = report.iter().all(|v| v.ok());
let body = json!({
"intact": intact,
"recorded_at": recorded.recorded_at,
"paths": report,
});
ok_json(&body)
}
#[tool(description = "Bench: read bench_history.jsonl for <repo> (one BenchRun per line).")]
async fn bench_history(
&self,
Parameters(args): Parameters<RepoArg>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let repo = s.loaded.nornir.repo.get(&args.repo).ok_or_else(|| {
McpError::invalid_params(format!("no [repo.{}]", args.repo), None)
})?;
let history = config::Nornir::repo_dir(&s.loaded.workspace_root, &args.repo)
.join(if repo.history.is_empty() { "bench_history.jsonl" } else { &repo.history });
let runs = bench::history::read_all(&history).map_err(internal)?;
let body = serde_json::to_string_pretty(&runs).map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "Release: run the no-path-patches gate against <repo>'s Cargo.toml.")]
async fn release_gate_path_patches(
&self,
Parameters(args): Parameters<RepoArg>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let repo_root = config::Nornir::repo_dir(&s.loaded.workspace_root, &args.repo);
release::gate::no_path_patches(&repo_root).map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(format!(
"ok: no [patch.crates-io] znippy entries in {}",
repo_root.display()
))]))
}
#[tool(description = "Release: nexus_floor gate — holger_ops_sec ≥ nexus_ops_sec for the latest BenchRun of <repo>.")]
async fn release_gate_nexus_floor(
&self,
Parameters(args): Parameters<RepoArg>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let (root, repo) = repo_ctx(&s, &args.repo).map_err(internal)?;
let run = mcp_last_run(&root, repo).map_err(internal)?;
release::gate::nexus_floor(&run).map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(format!(
"ok: nexus_floor on v{}", run.version
))]))
}
#[tool(description = "Release: no_regression gate — compare latest BenchRun to same-machine history; fails if any metric drops > max_regression_pct.")]
async fn release_gate_no_regression(
&self,
Parameters(args): Parameters<RepoArg>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let (root, repo) = repo_ctx(&s, &args.repo).map_err(internal)?;
let run = mcp_last_run(&root, repo).map_err(internal)?;
let pct = if repo.gates.max_regression_pct > 0.0 { repo.gates.max_regression_pct } else { 10.0 };
let hp = root.join(if repo.history.is_empty() { "bench_history.jsonl" } else { &repo.history });
release::gate::no_regression(&run, &hp, pct).map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(format!(
"ok: no_regression ≤{:.1}% on v{}", pct, run.version
))]))
}
#[tool(description = "Docs: scaffold `.nornir/` for <repo> (migrate any existing README.md/CHANGELOG.md into it).")]
async fn docs_init(
&self,
Parameters(args): Parameters<RepoArg>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let (root, _) = repo_ctx(&s, &args.repo).map_err(internal)?;
let layout = nornir::docs::RepoLayout::new(&root);
let srcs = nornir::docs::init_repo(&layout).map_err(internal)?;
let body = serde_json::json!({
"repo": args.repo,
"nornir_dir": layout.nornir_dir(),
"sources": srcs,
});
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string_pretty(&body).unwrap_or_default(),
)]))
}
#[tool(description = "Docs: render every managed doc for <repo> from .nornir/ (full rewrite, chmod-aware).")]
async fn docs_render(
&self,
Parameters(args): Parameters<RepoArg>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let (root, repo) = repo_ctx(&s, &args.repo).map_err(internal)?;
let layout = nornir::docs::RepoLayout::new(&root);
let last = mcp_last_run(&root, repo).ok();
let ctx = nornir::docs::Ctx::new(&root, &s.loaded.workspace_root, last.as_ref());
let reports = nornir::docs::render_all(&layout, &ctx).map_err(internal)?;
let body = serde_json::json!({
"repo": args.repo,
"reports": reports.iter().map(|r| serde_json::json!({
"output": r.output,
"bytes": r.bytes,
"changed": r.changed,
"sections": r.sections,
})).collect::<Vec<_>>(),
});
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string_pretty(&body).unwrap_or_default(),
)]))
}
#[tool(description = "Docs: dry-run check that every artifact (README.md, CHANGELOG.md) matches its .nornir/ source.")]
async fn docs_check(
&self,
Parameters(args): Parameters<RepoArg>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let (root, repo) = repo_ctx(&s, &args.repo).map_err(internal)?;
let layout = nornir::docs::RepoLayout::new(&root);
let last = mcp_last_run(&root, repo).ok();
let ctx = nornir::docs::Ctx::new(&root, &s.loaded.workspace_root, last.as_ref());
nornir::docs::render_check_all(&layout, &ctx).map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(format!(
"ok: every doc in {} matches its source", args.repo
))]))
}
#[tool(description = "Docs: list historical exports recorded in .nornir/warehouse/docs/ (newest first). Optional filters: doc, version, format, limit.")]
async fn docs_history(
&self,
Parameters(args): Parameters<DocsHistoryArgs>,
) -> Result<CallToolResult, McpError> {
let warehouse_root = {
let s = self.state.lock().await;
repo_ctx(&s, &args.repo).map_err(internal)?;
s.loaded.warehouse_root()
};
let repo = args.repo.clone();
let filter = nornir::docs::ExportFilter {
doc_name: args.doc.clone(),
version: args.version.clone(),
format: args.format.clone(),
limit: args.limit.or(Some(50)),
};
let rows = tokio::task::spawn_blocking(move || -> anyhow::Result<_> {
let wh = IcebergWarehouse::open(&warehouse_root)?;
nornir::docs::list_doc_exports(&wh, &repo, &filter)
})
.await
.map_err(internal)?
.map_err(internal)?;
let body = serde_json::json!({
"repo": args.repo,
"rows": rows,
});
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string_pretty(&body).unwrap_or_default(),
)]))
}
#[tool(description = "Docs: render the WHOLE doc set for <repo> — every .nornir/*.md plus non-generated <repo>/*.md — into one typst book (format: pdf | html | md, default pdf). Renders managed docs first, writes the current artifact to <repo>/docs/book.<ext>, and historizes it in the Iceberg doc_exports table under doc name 'book'. Requires the docs-export build feature.")]
async fn docs_book(
&self,
Parameters(args): Parameters<DocsBookArgs>,
) -> Result<CallToolResult, McpError> {
#[cfg(not(feature = "docs-export"))]
{
let _ = (&args.repo, &args.format);
return Err(internal(
"nornir-mcp was built without the `docs-export` feature; \
rebuild with `--features mcp,docs-export` to use docs_book",
));
}
#[cfg(feature = "docs-export")]
{
let (root, repo_name, workspace_root, warehouse_root, last) = {
let s = self.state.lock().await;
let (root, repo) = repo_ctx(&s, &args.repo).map_err(internal)?;
let last = mcp_last_run(&root, repo).ok();
(
root,
args.repo.clone(),
s.loaded.workspace_root.clone(),
s.loaded.warehouse_root(),
last,
)
};
let fmt_str = args.format.clone().unwrap_or_else(|| "pdf".to_string());
let (out_path, nbytes, sources, record) =
tokio::task::spawn_blocking(move || -> anyhow::Result<_> {
let layout = nornir::docs::RepoLayout::new(&root);
let ctx = nornir::docs::Ctx::new(&root, &workspace_root, last.as_ref());
nornir::docs::render_all(&layout, &ctx)?;
let format = nornir::docs::DocFormat::parse(&fmt_str)?;
let (bytes, sources) = nornir::docs::build_book(&root, &ctx, format)?;
let version = nornir::docs::resolve_version(&root);
let ext = format.extension();
let out_path = layout.export_path("book", ext);
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&out_path, &bytes)?;
let workspace = workspace_root
.file_name()
.and_then(|x| x.to_str())
.unwrap_or("_workspace")
.to_string();
let git_sha =
nornir::gitio::head_sha(&root).unwrap_or_else(|_| "unknown".to_string());
let wh = IcebergWarehouse::open(&warehouse_root)?;
let record = nornir::docs::record_doc_export(
&wh, &workspace, &repo_name, "book", &version, ext, &git_sha, &bytes,
)?;
Ok((out_path, bytes.len(), sources, record))
})
.await
.map_err(internal)?
.map_err(internal)?;
let body = serde_json::json!({
"repo": args.repo,
"format": args.format.as_deref().unwrap_or("pdf"),
"sources": sources,
"bytes": nbytes,
"out": out_path,
"sha256": record.sha256,
"git_sha": record.git_sha,
"export_id": record.export_id,
});
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string_pretty(&body).unwrap_or_default(),
)]))
}
}
#[tool(description = "Release: docs_fresh gate — README.md generated sections must be in sync with latest BenchRun.")]
async fn release_gate_docs_fresh(
&self,
Parameters(args): Parameters<RepoArg>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let (root, repo) = repo_ctx(&s, &args.repo).map_err(internal)?;
let layout = nornir::docs::RepoLayout::new(&root);
let run = mcp_last_run(&root, repo).map_err(internal)?;
let ctx = nornir::docs::Ctx::new(&root, &s.loaded.workspace_root, Some(&run));
nornir::docs::render_check_all(&layout, &ctx).map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(format!(
"ok: docs_fresh on {}", root.display()
))]))
}
#[tool(description = "Release: run every gate enabled in [repo.<name>.gates] for <repo>; returns JSON {passed:[...], failed:[{name,error}]}. Roundtrip invokes `cargo test --test roundtrip_<kind> --release` per configured kind.")]
async fn release_gate_all(
&self,
Parameters(args): Parameters<RepoArg>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let (root, repo) = repo_ctx(&s, &args.repo).map_err(internal)?;
let g = &repo.gates;
let mut passed: Vec<String> = Vec::new();
let mut failed: Vec<serde_json::Value> = Vec::new();
macro_rules! push {
($n:expr, $r:expr) => {
match $r {
Ok(()) => passed.push($n.into()),
Err(e) => failed.push(serde_json::json!({"name": $n, "error": format!("{e:#}")})),
}
};
}
if g.no_path_patches {
push!("no_path_patches", release::gate::no_path_patches(&root));
}
let last = mcp_last_run(&root, repo);
if g.nexus_floor {
push!("nexus_floor", last.as_ref().map_err(|e| anyhow::anyhow!("{e:#}")).and_then(|r| release::gate::nexus_floor(r)));
}
if g.no_regression {
let pct = if g.max_regression_pct > 0.0 { g.max_regression_pct } else { 10.0 };
let hp = root.join(if repo.history.is_empty() { "bench_history.jsonl" } else { &repo.history });
push!("no_regression",
last.as_ref().map_err(|e| anyhow::anyhow!("{e:#}")).and_then(|r| release::gate::no_regression(r, &hp, pct)));
}
if !g.integration_roundtrip.is_empty() {
let kinds: Vec<&str> = g.integration_roundtrip.iter().map(|s| s.as_str()).collect();
push!("integration_roundtrip",
nornir::release::gate::integration_roundtrip_via_cargo_test(&root, &kinds));
}
if g.docs_fresh {
let r: anyhow::Result<()> = (|| {
let run = last.as_ref().map_err(|e| anyhow::anyhow!("{e:#}"))?;
let layout = nornir::docs::RepoLayout::new(&root);
let ctx = nornir::docs::Ctx::new(&root, &s.loaded.workspace_root, Some(run));
nornir::docs::render_check_all(&layout, &ctx)
})();
push!("docs_fresh", r);
}
if g.guard_intact {
let r: anyhow::Result<()> = (|| {
let recorded = guard::read_manifest(&s.loaded.workspace_root)?;
guard::intact(&s.loaded.workspace_root, &recorded)
})();
push!("guard_intact", r);
}
let body = serde_json::json!({"repo": args.repo, "passed": passed, "failed": failed});
Ok(CallToolResult::success(vec![Content::text(serde_json::to_string_pretty(&body).unwrap())]))
}
#[tool(description = "Full-text BM25 search over indexed corpora. \
Run `nornir index build` first. Args: query (Tantivy syntax), \
optional corpus (docs|code|bench_history|changelog|config), \
optional repo (top-level workspace dir), optional limit.")]
async fn search(
&self,
Parameters(args): Parameters<SearchArgs>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let idx = index::Index::open(&s.loaded.workspace_root).map_err(internal)?;
let corpus = match args.corpus.as_deref() {
None => None,
Some(name) => Some(
index::Corpus::parse(name)
.ok_or_else(|| McpError::invalid_params(format!("unknown corpus: {name}"), None))?,
),
};
let hits = idx
.search(&args.query, corpus, args.repo.as_deref(), args.limit.unwrap_or(10))
.map_err(internal)?;
let body = serde_json::to_string_pretty(&hits).map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "DWARF symbol lookup: extract every function symbol \
from a built binary and filter by name substring. Returns JSON \
array of {name, name_demangled, name_mangled, file, line, size_bytes, krate}. \
`binary` may be relative to workspace root.")]
async fn symbol_lookup(
&self,
Parameters(args): Parameters<SymbolLookupArgs>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let bin = resolve_binary(&s.loaded.workspace_root, &args.binary);
let syms = introspect::artifact::extract_symbols(&bin, &s.loaded.workspace_root)
.map_err(internal)?;
let hits: Vec<_> = introspect::artifact::lookup(&syms, &args.pattern)
.into_iter()
.take(args.limit.unwrap_or(25))
.cloned()
.collect();
let body = serde_json::to_string_pretty(&hits).map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "DWARF defined-in lookup: list every function symbol \
defined in source files whose path ends with `file`. \
`binary` may be relative to workspace root.")]
async fn defined_in(
&self,
Parameters(args): Parameters<DefinedInArgs>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let bin = resolve_binary(&s.loaded.workspace_root, &args.binary);
let syms = introspect::artifact::extract_symbols(&bin, &s.loaded.workspace_root)
.map_err(internal)?;
let hits: Vec<_> = introspect::artifact::defined_in(&syms, &args.file)
.into_iter()
.take(args.limit.unwrap_or(100))
.cloned()
.collect();
let body = serde_json::to_string_pretty(&hits).map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "DWARF inline-callgraph: functions that call `name`. \
Only inlined edges are visible at this layer — indirect calls (trait \
objects, fn pointers) and non-inlined direct calls are NOT included. \
Use demangled names with generics stripped (e.g. `nornir::index::Index::build`).")]
async fn callers_of(
&self,
Parameters(args): Parameters<CallQueryArgs>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let bin = resolve_binary(&s.loaded.workspace_root, &args.binary);
let edges = introspect::callgraph_dwarf::extract_callgraph(&bin, &s.loaded.workspace_root)
.map_err(internal)?;
let cg = introspect::callgraph_dwarf::Callgraph::from_edges(&edges);
let body = serde_json::to_string_pretty(&cg.callers_of(&args.name)).map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "DWARF inline-callgraph: functions called by `name`. \
Inlined edges only (see `callers_of` for caveats).")]
async fn callees_of(
&self,
Parameters(args): Parameters<CallQueryArgs>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let bin = resolve_binary(&s.loaded.workspace_root, &args.binary);
let edges = introspect::callgraph_dwarf::extract_callgraph(&bin, &s.loaded.workspace_root)
.map_err(internal)?;
let cg = introspect::callgraph_dwarf::Callgraph::from_edges(&edges);
let body = serde_json::to_string_pretty(&cg.callees_of(&args.name)).map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "DWARF inline-callgraph: shortest call chain from `from` to `to` \
(BFS over inlined edges). Returns the list of function names along the path, \
or `null` when no path exists.")]
async fn path_between(
&self,
Parameters(args): Parameters<PathBetweenArgs>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let bin = resolve_binary(&s.loaded.workspace_root, &args.binary);
let edges = introspect::callgraph_dwarf::extract_callgraph(&bin, &s.loaded.workspace_root)
.map_err(internal)?;
let cg = introspect::callgraph_dwarf::Callgraph::from_edges(&edges);
let path = cg.path_between(&args.from, &args.to);
let body = serde_json::to_string_pretty(&path).map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "Knowledge symbol lookup over the persisted syn graph: symbols in <repo> \
whose item name contains `arg` (case-insensitive). Reads the latest `knowledge scan --persist` \
snapshot from iceberg — no compiled binary needed (unlike DWARF symbol_lookup). Returns a JSON \
array of {crate_name, module_path, item_kind, item_name, visibility, file, line, doc_lines, \
signature}. Empty array if the repo has no persisted snapshot.")]
async fn knowledge_symbol_lookup(
&self,
Parameters(args): Parameters<KnowledgeSymbolArgs>,
) -> Result<CallToolResult, McpError> {
let limit = args.limit.unwrap_or(50);
let body = self
.knowledge_query_json(args.repo, move |view| {
serde_json::to_string_pretty(&view.symbol_lookup(&args.arg, limit))
})
.await?;
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "Knowledge defined-in over the persisted syn graph: symbols in <repo> defined \
in source files whose path ends with `arg`. Reads the latest persisted snapshot from iceberg — \
no compiled binary needed. Returns the same JSON shape as knowledge_symbol_lookup.")]
async fn knowledge_defined_in(
&self,
Parameters(args): Parameters<KnowledgeSymbolArgs>,
) -> Result<CallToolResult, McpError> {
let limit = args.limit.unwrap_or(100);
let body = self
.knowledge_query_json(args.repo, move |view| {
let hits: Vec<_> = view.defined_in(&args.arg).into_iter().take(limit).collect();
serde_json::to_string_pretty(&hits)
})
.await?;
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "Knowledge callers over the persisted syn graph: call edges in <repo> that \
invoke `name` — matches a bare callee or any path-qualified callee whose last segment is \
`name` (a query of `new` finds `Arc::new`, `Foo::new`). Reads the latest persisted snapshot \
from iceberg — no compiled binary needed (unlike DWARF callers_of). Returns a JSON array of \
{crate_name, caller_path, callee_ident, call_kind, file, line}.")]
async fn knowledge_callers(
&self,
Parameters(args): Parameters<KnowledgeCallArgs>,
) -> Result<CallToolResult, McpError> {
let limit = args.limit.unwrap_or(100);
let body = self
.knowledge_query_json(args.repo, move |view| {
let hits: Vec<_> = view.callers_of(&args.name).into_iter().take(limit).collect();
serde_json::to_string_pretty(&hits)
})
.await?;
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "Knowledge callees over the persisted syn graph: call edges in <repo> emitted \
from a caller whose path ends with `name`. Reads the latest persisted snapshot from iceberg — \
no compiled binary needed. Returns the same JSON shape as knowledge_callers.")]
async fn knowledge_callees(
&self,
Parameters(args): Parameters<KnowledgeCallArgs>,
) -> Result<CallToolResult, McpError> {
let limit = args.limit.unwrap_or(100);
let body = self
.knowledge_query_json(args.repo, move |view| {
let hits: Vec<_> = view.callees_of(&args.name).into_iter().take(limit).collect();
serde_json::to_string_pretty(&hits)
})
.await?;
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "Knowledge call-path over the persisted syn graph: shortest call chain in \
<repo> from `from` to `to` (BFS over call edges, following caller→callee). Both ends match by \
last path segment, so `run_pipeline`→`commit` finds a chain through `Repo::commit`. Reads the \
latest persisted snapshot from iceberg — no compiled binary needed (unlike DWARF path_between). \
Returns a JSON array of identifiers along the path, or `null` when unreachable. Approximate: \
syn callees are idents, not resolved defining paths, so same-named functions collapse.")]
async fn knowledge_call_path(
&self,
Parameters(args): Parameters<KnowledgeCallPathArgs>,
) -> Result<CallToolResult, McpError> {
let body = self
.knowledge_query_json(args.repo, move |view| {
serde_json::to_string_pretty(&view.call_path(&args.from, &args.to))
})
.await?;
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "Submit a new idea into the intake funnel. Returns the assigned idea id (e.g. \"i-007\"). Use this when the user or agent surfaces something worth doing but the work hasn't been planned yet.")]
async fn funnel_submit_idea(
&self,
Parameters(args): Parameters<FunnelSubmitIdeaArgs>,
) -> Result<CallToolResult, McpError> {
let mut s = self.state.lock().await;
let id = IdeaId::seq(s.funnel.funnel.next_idea);
let ev = FunnelEvent::IdeaSubmitted {
id: id.clone(),
source: args.source.unwrap_or_else(|| "mcp".into()),
text: args.text,
refs: Vec::new(),
ts: Utc::now(),
};
s.funnel.record_async(ev).await.map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(id.as_str().to_string())]))
}
#[tool(description = "Create a plan that refines an existing idea into executable nodes. Auto-activates the plan. Returns the new plan id (e.g. \"p-003\"). Add nodes with funnel_add_node + funnel_link.")]
async fn funnel_create_plan(
&self,
Parameters(args): Parameters<FunnelCreatePlanArgs>,
) -> Result<CallToolResult, McpError> {
let mut s = self.state.lock().await;
let plan_id = PlanId::seq(s.funnel.funnel.next_plan);
let now = Utc::now();
s.funnel.record_async(FunnelEvent::PlanCreated {
id: plan_id.clone(),
idea_id: IdeaId::new(args.idea_id),
summary: args.summary,
planner: "mcp".into(),
ts: now,
}).await.map_err(internal)?;
s.funnel.record_async(FunnelEvent::PlanStatusChanged {
plan_id: plan_id.clone(),
status: PlanStatus::Active,
why: None,
ts: Utc::now(),
}).await.map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(plan_id.as_str().to_string())]))
}
#[tool(description = "Add a node to a plan. `kind` is a free verb like \"code:write\", \"test:run\", \"doc:update\". Optionally pass `needs` (other node-ids in the same plan) to wire up dependencies in a single call. Returns the new node id (e.g. \"n-042\").")]
async fn funnel_add_node(
&self,
Parameters(args): Parameters<FunnelAddNodeArgs>,
) -> Result<CallToolResult, McpError> {
let mut s = self.state.lock().await;
let plan_id = PlanId::new(args.plan_id);
let node_id = NodeId::seq(s.funnel.funnel.next_node);
let now = Utc::now();
let mut params = serde_json::Map::new();
if let Some(t) = args.title {
params.insert("title".into(), serde_json::Value::String(t));
}
s.funnel.record_async(FunnelEvent::NodeAdded {
plan_id: plan_id.clone(),
node_id: node_id.clone(),
kind: args.kind,
params,
targets: args.targets,
prompt_excerpt: args.prompt,
ts: now,
}).await.map_err(internal)?;
for from in &args.needs {
s.funnel.record_async(FunnelEvent::EdgeAdded {
plan_id: plan_id.clone(),
from_node: NodeId::new(from.clone()),
to_node: node_id.clone(),
ts: Utc::now(),
}).await.map_err(internal)?;
}
s.funnel.funnel.promote_ready();
Ok(CallToolResult::success(vec![Content::text(node_id.as_str().to_string())]))
}
#[tool(description = "Add a dependency edge: node `to` will only become ready once node `from` is done. Both must belong to the same plan.")]
async fn funnel_link(
&self,
Parameters(args): Parameters<FunnelLinkArgs>,
) -> Result<CallToolResult, McpError> {
let mut s = self.state.lock().await;
s.funnel.record_async(FunnelEvent::EdgeAdded {
plan_id: PlanId::new(args.plan_id),
from_node: NodeId::new(args.from),
to_node: NodeId::new(args.to),
ts: Utc::now(),
}).await.map_err(internal)?;
s.funnel.funnel.promote_ready();
Ok(CallToolResult::success(vec![Content::text("ok".to_string())]))
}
#[tool(description = "What should the agent work on next? Returns a JSON array of ready PlanNodes (all deps satisfied) across every active plan, in stable topo order. Empty array = nothing ready (either all done, all blocked, or no active plans). Call this whenever your context resets.")]
async fn funnel_next(&self) -> Result<CallToolResult, McpError> {
let mut s = self.state.lock().await;
s.funnel.funnel.promote_ready();
let next = topo_ready(&mut s.funnel.funnel);
let body = serde_json::to_string_pretty(&next).map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "Flip a node's status. `status` is one of: ready, active, blocked, done, abandoned. Pass `why` when blocking or abandoning. Use `done` after the actual work lands; the funnel will unblock dependents automatically on the next funnel_next call.")]
async fn funnel_status(
&self,
Parameters(args): Parameters<FunnelStatusArgs>,
) -> Result<CallToolResult, McpError> {
let status = match args.status.as_str() {
"ready" => NodeStatus::Ready,
"active" | "in_progress" => NodeStatus::InProgress,
"blocked" => NodeStatus::Blocked,
"done" => NodeStatus::Done,
"failed" => NodeStatus::Failed,
"abandoned" => NodeStatus::Failed, other => {
return Err(McpError::invalid_params(
format!("unknown status {other:?}; expected ready|active|blocked|done|failed"),
None,
));
}
};
let mut s = self.state.lock().await;
s.funnel.record_async(FunnelEvent::NodeStatusChanged {
plan_id: PlanId::new(args.plan_id),
node_id: NodeId::new(args.node_id),
status,
why: args.why,
ts: Utc::now(),
}).await.map_err(internal)?;
s.funnel.funnel.promote_ready();
Ok(CallToolResult::success(vec![Content::text("ok".to_string())]))
}
#[tool(description = "Dump the entire funnel: ideas with their plans, each plan's nodes with status, and the dependency edges. Useful for orienting after a context reset before calling funnel_next.")]
async fn funnel_show(&self) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let f = &s.funnel.funnel;
let mut out = String::new();
use std::fmt::Write;
let _ = writeln!(out, "ideas: {}, plans: {}", f.ideas.len(), f.plans.len());
for (iid, idea) in &f.ideas {
let _ = writeln!(out, " {} [{}] {}", iid.as_str(), idea.source, idea.text);
}
for (pid, plan) in &f.plans {
let _ = writeln!(
out,
" {} (idea {}) [{:?}] {} — {} nodes, {} edges",
pid.as_str(),
plan.idea_id.as_str(),
plan.status,
plan.summary,
plan.nodes.len(),
plan.edges.len(),
);
for (nid, n) in &plan.nodes {
let title = n.params.get("title").and_then(|v| v.as_str()).unwrap_or("");
let _ = writeln!(out, " {} [{:?}] {} {}", nid.as_str(), n.status, n.kind, title);
}
}
Ok(CallToolResult::success(vec![Content::text(out)]))
}
}
impl NornirServer {
async fn mimir(&self) -> Result<Arc<MimirCtx>, McpError> {
let mut s = self.state.lock().await;
if let Some(o) = &s.mimir {
return Ok(o.clone());
}
let desc_path = resolve_workspace_descriptor(&s.loaded).map_err(internal)?;
let desc = WorkspaceDescriptor::load(&desc_path).map_err(internal)?;
let graph = WorkspaceGraph::build(&desc).map_err(internal)?;
let ctx = Arc::new(MimirCtx {
graph,
workspace_name: desc.workspace.name.clone(),
});
s.mimir = Some(ctx.clone());
Ok(ctx)
}
#[cfg(any(feature = "embed-tract", feature = "embed-ort"))]
async fn embedder(&self) -> Result<Arc<dyn nornir::vector::store::Embedder>, McpError> {
{
let s = self.state.lock().await;
if let Some(e) = &s.embedder {
return Ok(e.clone());
}
}
let e: Arc<dyn nornir::vector::store::Embedder> = tokio::task::spawn_blocking(|| {
nornir::vector::load_embedder().map(Arc::from)
})
.await
.map_err(internal)?
.map_err(internal)?;
let mut s = self.state.lock().await;
let cached = s.embedder.get_or_insert(e).clone();
Ok(cached)
}
async fn knowledge_query_json<F>(&self, repo: String, f: F) -> Result<String, McpError>
where
F: FnOnce(&nornir::knowledge::query::KnowledgeView) -> serde_json::Result<String>
+ Send
+ 'static,
{
let warehouse_root = {
let s = self.state.lock().await;
s.loaded.warehouse_root()
};
tokio::task::spawn_blocking(move || -> Result<String, McpError> {
let wh = IcebergWarehouse::open(&warehouse_root)
.with_context(|| format!("open warehouse at {}", warehouse_root.display()))
.map_err(internal)?;
let view = nornir::knowledge::query::load_latest(&wh, &repo).map_err(internal)?;
f(&view).map_err(internal)
})
.await
.map_err(internal)?
}
}
#[tool_handler]
impl ServerHandler for NornirServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(
ServerCapabilities::builder().enable_tools().build(),
)
.with_server_info(Implementation::from_build_env())
.with_instructions(
"nornir — companion to cargo. Tools: repos_list, guard_{status,apply,verify}, \
deps_of, dependents_of, affected_by_change, build_order, dep_path, \
external_dep_users, dep_graph_mermaid, changed_since_last_release, \
bench_history, release_gate_{path_patches,nexus_floor,no_regression,docs_fresh,all}, \
docs_{init,render,check,history}, \
search, symbol_lookup, defined_in, callers_of, callees_of, path_between, \
knowledge_{symbol_lookup,defined_in,callers,callees,call_path}, \
funnel_{submit_idea,create_plan,add_node,link,next,status,show}. \
The dependency MÃmir (deps_of/dependents_of/affected_by_change/dep_path/\
build_order/changed_since_last_release) answers cross-repo graph questions — \
blast radius, build order, re-run set — so a small model needn't reason over the \
whole graph; it reads a nornir-workspace.toml (set NORNIR_WORKSPACE to override). \
The funnel is a persistent DAG of ideas → plans → nodes that survives agent \
context loss; call funnel_show then funnel_next after any restart to find out \
what to work on. The server reads workspace_holger/release/nornir.toml at start; \
restart to pick up edits."
.to_string(),
)
}
}
fn format_status(report: &[guard::PathStatus]) -> String {
let mut s = String::new();
s.push_str(&format!("{:<8} {:<8} {:<8} path\n", "exists", "writable", "changed"));
for p in report {
s.push_str(&format!(
"{:<8} {:<8} {:<8} {}\n",
yn(p.exists), yn(p.writable), yn(p.changed), p.path.display()
));
}
s
}
fn resolve_binary(workspace_root: &std::path::Path, binary: &str) -> std::path::PathBuf {
let p = std::path::PathBuf::from(binary);
if p.is_absolute() { p } else { workspace_root.join(p) }
}
fn yn(b: bool) -> &'static str { if b { "yes" } else { "no" } }
fn internal<E: std::fmt::Display>(e: E) -> McpError {
McpError::internal_error(e.to_string(), None)
}
fn ok_json<T: serde::Serialize>(value: &T) -> Result<CallToolResult, McpError> {
let text = serde_json::to_string_pretty(value).map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(text)]))
}
fn ensure_repo(graph: &WorkspaceGraph, repo: &str) -> Result<(), McpError> {
if graph.facts.contains_key(repo) {
Ok(())
} else {
let known: Vec<&str> = graph.facts.keys().map(String::as_str).collect();
Err(McpError::invalid_params(
format!("unknown repo `{repo}`; known repos: {}", known.join(", ")),
None,
))
}
}
fn graph_mermaid(graph: &WorkspaceGraph) -> String {
use std::fmt::Write;
let mut out = String::from("graph LR\n");
for name in graph.facts.keys() {
let _ = writeln!(out, " {}[\"{}\"]", mermaid_id(name), name);
}
for e in &graph.edges {
let via: Vec<&str> = e.via.iter().map(String::as_str).collect();
let _ = writeln!(
out,
" {} -->|\"{}\"| {}",
mermaid_id(&e.from),
via.join(", "),
mermaid_id(&e.to),
);
}
out
}
fn mermaid_id(name: &str) -> String {
name.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect()
}
fn resolve_workspace_descriptor(loaded: &Loaded) -> Result<PathBuf> {
if let Some(p) = std::env::var_os("NORNIR_WORKSPACE") {
let p = PathBuf::from(p);
if p.exists() {
return Ok(p);
}
anyhow::bail!("NORNIR_WORKSPACE={} does not exist", p.display());
}
let mut candidates = vec![
loaded.workspace_root.join("nornir-workspace.toml"),
loaded.workspace_root.join("workspace_holger/nornir-workspace.toml"),
];
if let Some(dir) = loaded.config_path.parent() {
candidates.push(dir.join("nornir-workspace.toml"));
}
for c in &candidates {
if c.exists() {
return Ok(c.clone());
}
}
anyhow::bail!(
"no nornir-workspace.toml found (set NORNIR_WORKSPACE or create one); searched: {}",
candidates
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
)
}
fn repo_ctx<'a>(
s: &'a tokio::sync::MutexGuard<'a, State>,
repo_name: &str,
) -> anyhow::Result<(PathBuf, &'a config::Repo)> {
let repo = s.loaded.nornir.repo.get(repo_name)
.ok_or_else(|| anyhow::anyhow!("repo `{repo_name}` not in nornir.toml"))?;
let root = config::Nornir::repo_dir(&s.loaded.workspace_root, repo_name);
Ok((root, repo))
}
fn mcp_last_run(repo_root: &std::path::Path, repo: &config::Repo) -> anyhow::Result<bench::BenchRun> {
let path = repo_root.join(if repo.history.is_empty() { "bench_history.jsonl" } else { &repo.history });
let runs = bench::history::read_all(&path)?;
runs.into_iter().last().ok_or_else(|| anyhow::anyhow!("no bench runs in {}", path.display()))
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "nornir_mcp=info".into()),
)
.with_writer(std::io::stderr)
.with_ansi(false)
.init();
let config_path = std::env::var_os("NORNIR_CONFIG").map(PathBuf::from);
let loaded = match config_path {
Some(p) => config::load_explicit(&p)?,
None => config::discover(&std::env::current_dir()?)?,
};
eprintln!("starting nornir-mcp; config={}", loaded.config_path.display());
let server = NornirServer::new(loaded).await?.serve(stdio()).await?;
server.waiting().await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use nornir::config::Nornir;
#[test]
fn mermaid_id_sanitizes_non_alnum() {
assert_eq!(mermaid_id("snippy-core"), "snippy_core");
assert_eq!(mermaid_id("a.b/c"), "a_b_c");
assert_eq!(mermaid_id("plain"), "plain");
}
#[test]
fn resolve_descriptor_honors_env_override() {
let tmp = tempfile::tempdir().unwrap();
let desc = tmp.path().join("ws.toml");
std::fs::write(&desc, "[workspace]\nname=\"x\"\n").unwrap();
let loaded = Loaded {
nornir: Nornir::default(),
config_path: tmp.path().join("nornir.toml"),
workspace_root: tmp.path().to_path_buf(),
};
unsafe { std::env::set_var("NORNIR_WORKSPACE", &desc) };
let got = resolve_workspace_descriptor(&loaded).unwrap();
unsafe { std::env::remove_var("NORNIR_WORKSPACE") };
assert_eq!(got, desc);
}
#[test]
fn resolve_descriptor_errors_when_missing() {
let tmp = tempfile::tempdir().unwrap();
let loaded = Loaded {
nornir: Nornir::default(),
config_path: tmp.path().join("nornir.toml"),
workspace_root: tmp.path().to_path_buf(),
};
unsafe { std::env::remove_var("NORNIR_WORKSPACE") };
assert!(resolve_workspace_descriptor(&loaded).is_err());
}
#[test]
fn agent_tool_surface_excludes_unlock_includes_mimir() {
let router = NornirServer::tool_router();
assert!(!router.has_route("guard_release"), "guard_release must not be exposed to agents");
for name in [
"guard_apply",
"guard_verify",
"deps_of",
"dependents_of",
"affected_by_change",
"build_order",
"dep_path",
"external_dep_users",
"dep_graph_mermaid",
"changed_since_last_release",
] {
assert!(router.has_route(name), "expected MCP tool `{name}` to be registered");
}
}
}