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, McpCall};
use nornir::workspace::descriptor::WorkspaceDescriptor;
use serde_json::json;
#[derive(Clone)]
struct NornirServer {
state: Arc<Mutex<State>>,
tool_router: ToolRouter<NornirServer>,
log_tx: Option<tokio::sync::mpsc::UnboundedSender<McpCall>>,
}
fn spawn_mcp_log_writer(
warehouse_root: PathBuf,
mut rx: tokio::sync::mpsc::UnboundedReceiver<McpCall>,
) {
std::thread::Builder::new()
.name("nornir-mcp-log".into())
.spawn(move || {
let wh = match IcebergWarehouse::open(&warehouse_root) {
Ok(w) => w,
Err(e) => {
eprintln!("mcp-log: warehouse open failed; telemetry off: {e:#}");
return;
}
};
while let Some(first) = rx.blocking_recv() {
let mut batch = vec![first];
while let Ok(more) = rx.try_recv() {
batch.push(more);
if batch.len() >= 256 {
break;
}
}
if let Err(e) = wh.append_mcp_calls(&batch) {
eprintln!("mcp-log: append {} call(s) failed: {e:#}", batch.len());
}
}
})
.expect("spawn nornir-mcp-log thread");
}
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 CratePublishedArgs {
name: 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 DwarfStoredArgs {
repo: String,
arg: String,
#[serde(default)]
sha: Option<String>,
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct DwarfPathArgs {
repo: String,
from: String,
to: String,
#[serde(default)]
sha: Option<String>,
}
#[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 DocsExportArgs {
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(),
);
let (log_tx, log_rx) = tokio::sync::mpsc::unbounded_channel::<McpCall>();
spawn_mcp_log_writer(loaded.warehouse_root(), log_rx);
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(),
log_tx: Some(log_tx),
})
}
#[tool(description = "Is a crate published on crates.io? Returns {published, max_version, \
versions_count, yanked_latest}. Use before depending on a crate (does it \
resolve for `cargo install` users?) or to check publish status after a \
release. Queries the sparse index directly; no cargo, no API key.")]
async fn crate_published(
&self,
Parameters(args): Parameters<CratePublishedArgs>,
) -> Result<CallToolResult, McpError> {
let name = args.name.to_lowercase();
let path = match name.len() {
0 => return Err(internal("empty crate name")),
1 => format!("1/{name}"),
2 => format!("2/{name}"),
3 => format!("3/{}/{name}", &name[..1]),
_ => format!("{}/{}/{name}", &name[..2], &name[2..4]),
};
let url = format!("https://index.crates.io/{path}");
let body = tokio::task::spawn_blocking(move || -> anyhow::Result<Option<String>> {
match ureq::get(&url).call() {
Ok(resp) => Ok(Some(resp.into_string()?)),
Err(ureq::Error::Status(404, _)) => Ok(None),
Err(e) => Err(e.into()),
}
})
.await
.map_err(internal)?
.map_err(internal)?;
let Some(body) = body else {
return ok_json(&serde_json::json!({ "name": name, "published": false }));
};
let mut versions = 0usize;
let mut max_version = String::new();
let mut yanked_latest = false;
for line in body.lines().filter(|l| !l.trim().is_empty()) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
versions += 1;
if let Some(vers) = v.get("vers").and_then(|x| x.as_str()) {
max_version = vers.to_string();
}
yanked_latest = v.get("yanked").and_then(|x| x.as_bool()).unwrap_or(false);
}
}
ok_json(&serde_json::json!({
"name": name,
"published": versions > 0,
"max_version": max_version,
"versions_count": versions,
"yanked_latest": yanked_latest,
}))
}
#[tool(description = "List repos declared in nornir.toml.")]
async fn repos_list(&self) -> Result<CallToolResult, McpError> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::repos_client::ReposClient::with_interceptor(channel, mcp_auth(bearer));
let resp = c.list(pb::Empty {}).await.map_err(internal)?.into_inner();
let names: Vec<String> = resp.repos.into_iter().map(|r| r.name).collect();
return ok_json(&names);
}
let s = self.state.lock().await;
let names: Vec<String> = s.loaded.nornir.repo.keys().cloned().collect();
ok_json(&names)
}
#[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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::mimir_client::MimirClient::with_interceptor(channel, mcp_auth(bearer));
let r = c.deps_of(pb::DepQuery { repo: args.repo.clone(), transitive: args.transitive })
.await.map_err(internal)?.into_inner();
return mimir_emit(&r.json);
}
let mimir = self.mimir().await?;
let v = nornir::mimir::deps_of(&mimir.graph, &args.repo, args.transitive).map_err(internal)?;
ok_json(&v)
}
#[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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::mimir_client::MimirClient::with_interceptor(channel, mcp_auth(bearer));
let r = c.dependents_of(pb::DepQuery { repo: args.repo.clone(), transitive: args.transitive })
.await.map_err(internal)?.into_inner();
return mimir_emit(&r.json);
}
let mimir = self.mimir().await?;
let v = nornir::mimir::dependents_of(&mimir.graph, &args.repo, args.transitive).map_err(internal)?;
ok_json(&v)
}
#[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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::mimir_client::MimirClient::with_interceptor(channel, mcp_auth(bearer));
let r = c.affected_by_change(pb::AffectedQuery { repos: args.repos.clone() })
.await.map_err(internal)?.into_inner();
return mimir_emit(&r.json);
}
let mimir = self.mimir().await?;
let v = nornir::mimir::affected_by_change(&mimir.graph, &args.repos).map_err(internal)?;
ok_json(&v)
}
#[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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::mimir_client::MimirClient::with_interceptor(channel, mcp_auth(bearer));
let r = c.build_order(pb::Empty {}).await.map_err(internal)?.into_inner();
return mimir_emit(&r.json);
}
let mimir = self.mimir().await?;
let v = nornir::mimir::build_order(&mimir.graph).map_err(internal)?;
ok_json(&v)
}
#[tool(description = "One-shot repo orientation for an agent: <repo>'s internal dependencies + dependents (with the crates that justify each edge), its build-order position, and a knowledge digest (symbol + call-edge counts, a sample of symbol names) when a syn scan has been persisted. Lets a small/local model get its bearings in ONE call instead of orchestrating deps_of/build_order/knowledge_* separately.")]
async fn repo_overview(
&self,
Parameters(args): Parameters<RepoArg>,
) -> Result<CallToolResult, McpError> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::mimir_client::MimirClient::with_interceptor(channel, mcp_auth(bearer));
let r = c.repo_overview(pb::RepoOnly { repo: args.repo.clone() })
.await.map_err(internal)?.into_inner();
return mimir_emit(&r.json);
}
let mimir = self.mimir().await?;
let warehouse_root = {
let s = self.state.lock().await;
s.loaded.warehouse_root()
};
let repo = args.repo.clone();
let value = tokio::task::spawn_blocking(move || -> anyhow::Result<serde_json::Value> {
let wh = IcebergWarehouse::open(&warehouse_root)?;
nornir::mimir::repo_overview(&mimir.graph, &wh, &repo)
})
.await
.map_err(internal)?
.map_err(internal)?;
ok_json(&value)
}
#[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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::mimir_client::MimirClient::with_interceptor(channel, mcp_auth(bearer));
let r = c.dep_path(pb::DepPathQuery { from: args.from.clone(), to: args.to.clone() })
.await.map_err(internal)?.into_inner();
return mimir_emit(&r.json);
}
let mimir = self.mimir().await?;
let v = nornir::mimir::dep_path(&mimir.graph, &args.from, &args.to).map_err(internal)?;
ok_json(&v)
}
#[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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::mimir_client::MimirClient::with_interceptor(channel, mcp_auth(bearer));
let r = c.external_dep_users(pb::CrateQuery { krate: args.krate.clone() })
.await.map_err(internal)?.into_inner();
return mimir_emit(&r.json);
}
let mimir = self.mimir().await?;
ok_json(&nornir::mimir::external_dep_users(&mimir.graph, &args.krate))
}
#[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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::mimir_client::MimirClient::with_interceptor(channel, mcp_auth(bearer));
let r = c.mermaid(pb::Empty {}).await.map_err(internal)?.into_inner();
return Ok(CallToolResult::success(vec![Content::text(r.json)]));
}
let mimir = self.mimir().await?;
Ok(CallToolResult::success(vec![Content::text(nornir::mimir::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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::mimir_client::MimirClient::with_interceptor(channel, mcp_auth(bearer));
let r = c.changed_since_last_release(pb::Empty {}).await.map_err(internal)?.into_inner();
return mimir_emit(&r.json);
}
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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::release_client::ReleaseClient::with_interceptor(channel, mcp_auth(bearer));
let r = c.trace(pb::TraceQuery { repo: args.repo.clone(), workspace: args.workspace.clone() })
.await.map_err(internal)?.into_inner();
return mimir_emit(&r.json);
}
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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::vector_client::VectorClient::with_interceptor(channel, mcp_auth(bearer));
let resp = c
.search(pb::VectorSearchRequest {
repo: args.repo.clone(),
query: args.query.clone(),
sha: args.sha.clone(),
limit: args.limit.unwrap_or(0) as u32,
})
.await
.map_err(internal)?
.into_inner();
return Ok(CallToolResult::success(vec![Content::text(resp.json)]));
}
#[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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::guard_client::GuardClient::with_interceptor(channel, mcp_auth(bearer));
let report = c.status(pb::Empty {}).await.map_err(internal)?.into_inner();
return Ok(CallToolResult::success(vec![Content::text(pb_guard_text(&report))]));
}
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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::guard_client::GuardClient::with_interceptor(channel, mcp_auth(bearer));
let report = c.apply(pb::Empty {}).await.map_err(internal)?.into_inner();
return Ok(CallToolResult::success(vec![Content::text(pb_guard_text(&report))]));
}
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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::guard_client::GuardClient::with_interceptor(channel, mcp_auth(bearer));
let r = c.verify(pb::Empty {}).await.map_err(internal)?.into_inner();
let paths: serde_json::Value =
serde_json::from_str(&r.paths_json).unwrap_or_else(|_| json!([]));
return ok_json(&json!({
"intact": r.intact, "recorded_at": r.recorded_at, "paths": paths,
}));
}
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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::bench_client::BenchClient::with_interceptor(channel, mcp_auth(bearer));
let resp = c.history(pb::RepoOnly { repo: args.repo.clone() })
.await.map_err(internal)?.into_inner();
let runs: Vec<bench::BenchRun> = resp.runs.into_iter().map(pb_bench_run).collect();
return ok_json(&runs);
}
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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::release_client::ReleaseClient::with_interceptor(channel, mcp_auth(bearer));
let g = c.gate_path_patches(pb::RepoOnly { repo: args.repo.clone() })
.await.map_err(internal)?.into_inner();
return pb_gate_result(g);
}
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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::release_client::ReleaseClient::with_interceptor(channel, mcp_auth(bearer));
let g = c.gate_nexus_floor(pb::RepoOnly { repo: args.repo.clone() })
.await.map_err(internal)?.into_inner();
return pb_gate_result(g);
}
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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::release_client::ReleaseClient::with_interceptor(channel, mcp_auth(bearer));
let g = c.gate_no_regression(pb::RepoOnly { repo: args.repo.clone() })
.await.map_err(internal)?.into_inner();
return pb_gate_result(g);
}
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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::docs_client::DocsClient::with_interceptor(channel, mcp_auth(bearer));
let r = c.init(pb::RepoOnly { repo: args.repo.clone() }).await.map_err(internal)?.into_inner();
return ok_json(&json!({"repo": r.repo, "status": r.status, "artifacts": r.artifacts, "detail": r.detail}));
}
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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::docs_client::DocsClient::with_interceptor(channel, mcp_auth(bearer));
let r = c.render(pb::RepoOnly { repo: args.repo.clone() }).await.map_err(internal)?.into_inner();
return ok_json(&json!({"repo": r.repo, "status": r.status, "artifacts": r.artifacts, "detail": r.detail}));
}
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 history = mcp_history(&root, repo);
let ctx = nornir::docs::Ctx::new(&root, &s.loaded.workspace_root, last.as_ref())
.with_history(&history);
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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::docs_client::DocsClient::with_interceptor(channel, mcp_auth(bearer));
let r = c.check(pb::RepoOnly { repo: args.repo.clone() }).await.map_err(internal)?.into_inner();
return if r.status == "ok" {
Ok(CallToolResult::success(vec![Content::text(format!(
"ok: every doc in {} matches its source", args.repo
))]))
} else {
Err(internal(format!("docs drift in {}: {}", args.repo, r.detail)))
};
}
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 history = mcp_history(&root, repo);
let ctx = nornir::docs::Ctx::new(&root, &s.loaded.workspace_root, last.as_ref())
.with_history(&history);
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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::docs_client::DocsClient::with_interceptor(channel, mcp_auth(bearer));
let r = c.history(pb::DocsHistoryRequest {
repo: args.repo.clone(),
doc: args.doc.clone().unwrap_or_default(),
version: args.version.clone().unwrap_or_default(),
format: args.format.clone().unwrap_or_default(),
limit: args.limit.unwrap_or(50) as u32,
}).await.map_err(internal)?.into_inner();
let rows: Vec<_> = r.entries.into_iter().map(|e| json!({
"doc": e.doc, "version": e.version, "format": e.format,
"path": e.path, "exported_at": e.exported_at, "size_bytes": e.size_bytes,
})).collect();
return ok_json(&json!({"repo": args.repo, "rows": rows}));
}
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> {
if server_target().is_some() {
return mcp_docs_export_remote(&args.repo, args.format.as_deref(), true).await;
}
#[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, history) = {
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();
let history = mcp_history(&root, repo);
(
root,
args.repo.clone(),
s.loaded.workspace_root.clone(),
s.loaded.warehouse_root(),
last,
history,
)
};
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())
.with_history(&history);
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 = "Docs: export the assembled README for <repo> to PDF/HTML/MD (format: pdf | html | md, default pdf). Renders managed docs first, writes the artifact to <repo>/docs/README.<ext>, and historizes it in the Iceberg doc_exports table under doc name 'README'. Single-doc counterpart to docs_book. Requires the docs-export build feature.")]
async fn docs_export(
&self,
Parameters(args): Parameters<DocsExportArgs>,
) -> Result<CallToolResult, McpError> {
if server_target().is_some() {
return mcp_docs_export_remote(&args.repo, args.format.as_deref(), false).await;
}
#[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_export",
));
}
#[cfg(feature = "docs-export")]
{
let (root, repo_name, workspace_root, warehouse_root, last, history) = {
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();
let history = mcp_history(&root, repo);
(
root,
args.repo.clone(),
s.loaded.workspace_root.clone(),
s.loaded.warehouse_root(),
last,
history,
)
};
let fmt_str = args.format.clone().unwrap_or_else(|| "pdf".to_string());
let (out_path, nbytes, 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())
.with_history(&history);
nornir::docs::render_all(&layout, &ctx)?;
let format = nornir::docs::DocFormat::parse(&fmt_str)?;
let bytes = nornir::docs::export_repo(&root, format)?;
let version = nornir::docs::resolve_version(&root);
let ext = format.extension();
let out_path = layout.export_path("README", 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, "README", &version, ext, &git_sha, &bytes,
)?;
Ok((out_path, bytes.len(), record))
})
.await
.map_err(internal)?
.map_err(internal)?;
let body = serde_json::json!({
"repo": args.repo,
"format": args.format.as_deref().unwrap_or("pdf"),
"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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::release_client::ReleaseClient::with_interceptor(channel, mcp_auth(bearer));
let g = c.gate_docs_fresh(pb::RepoOnly { repo: args.repo.clone() })
.await.map_err(internal)?.into_inner();
return pb_gate_result(g);
}
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 history = mcp_history(&root, repo);
let ctx = nornir::docs::Ctx::new(&root, &s.loaded.workspace_root, Some(&run))
.with_history(&history);
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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::release_client::ReleaseClient::with_interceptor(channel, mcp_auth(bearer));
let r = c.gate_all(pb::RepoOnly { repo: args.repo.clone() })
.await.map_err(internal)?.into_inner();
let failed: Vec<_> = r.failed.into_iter()
.map(|f| json!({"name": f.name, "error": f.error})).collect();
return ok_json(&json!({"repo": r.repo, "passed": r.passed, "failed": failed}));
}
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 history = mcp_history(&root, repo);
let ctx = nornir::docs::Ctx::new(&root, &s.loaded.workspace_root, Some(run))
.with_history(&history);
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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::search_client::SearchClient::with_interceptor(channel, mcp_auth(bearer));
let resp = c
.query(pb::SearchRequest {
query: args.query.clone(),
corpus: args.corpus.clone().unwrap_or_default(),
repo: args.repo.clone().unwrap_or_default(),
limit: args.limit.unwrap_or(10) as u32,
})
.await
.map_err(internal)?
.into_inner();
let hits: Vec<_> = resp
.hits
.into_iter()
.map(|h| {
json!({
"id": h.id, "corpus": h.corpus, "repo": h.repo, "path": h.path,
"score": h.score, "snippet": h.snippet, "title": h.title,
})
})
.collect();
return ok_json(&hits);
}
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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::introspect_client::IntrospectClient::with_interceptor(channel, mcp_auth(bearer));
let resp = c.symbol_lookup(pb::SymbolLookupRequest {
binary: args.binary.clone(), pattern: args.pattern.clone(),
limit: args.limit.unwrap_or(25) as u32,
}).await.map_err(internal)?.into_inner();
return ok_json(&pb_symbols_json(resp));
}
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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::introspect_client::IntrospectClient::with_interceptor(channel, mcp_auth(bearer));
let resp = c.defined_in(pb::DefinedInRequest {
binary: args.binary.clone(), suffix: args.file.clone(),
}).await.map_err(internal)?.into_inner();
return ok_json(&pb_symbols_json(resp));
}
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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::introspect_client::IntrospectClient::with_interceptor(channel, mcp_auth(bearer));
let resp = c.callers(pb::CallQuery { binary: args.binary.clone(), name: args.name.clone() })
.await.map_err(internal)?.into_inner();
return ok_json(&resp.names);
}
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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::introspect_client::IntrospectClient::with_interceptor(channel, mcp_auth(bearer));
let resp = c.callees(pb::CallQuery { binary: args.binary.clone(), name: args.name.clone() })
.await.map_err(internal)?.into_inner();
return ok_json(&resp.names);
}
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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::introspect_client::IntrospectClient::with_interceptor(channel, mcp_auth(bearer));
let resp = c.path_between(pb::PathBetweenRequest {
binary: args.binary.clone(), from: args.from.clone(), to: args.to.clone(),
}).await.map_err(internal)?.into_inner();
let v = if resp.names.is_empty() { serde_json::Value::Null } else { json!(resp.names) };
return ok_json(&v);
}
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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::knowledge_client::KnowledgeClient::with_interceptor(channel, mcp_auth(bearer));
let resp = c.symbol_lookup(pb::KnowledgeSymbolQuery {
repo: args.repo.clone(), arg: args.arg.clone(), limit: args.limit.unwrap_or(50) as u32,
}).await.map_err(internal)?.into_inner();
return ok_json(&pb_knowledge_symbols_json(resp));
}
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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::knowledge_client::KnowledgeClient::with_interceptor(channel, mcp_auth(bearer));
let resp = c.defined_in(pb::KnowledgeSymbolQuery {
repo: args.repo.clone(), arg: args.arg.clone(), limit: args.limit.unwrap_or(100) as u32,
}).await.map_err(internal)?.into_inner();
return ok_json(&pb_knowledge_symbols_json(resp));
}
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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::knowledge_client::KnowledgeClient::with_interceptor(channel, mcp_auth(bearer));
let resp = c.callers(pb::KnowledgeCallQuery {
repo: args.repo.clone(), name: args.name.clone(), limit: args.limit.unwrap_or(100) as u32,
}).await.map_err(internal)?.into_inner();
return ok_json(&pb_knowledge_calls_json(resp));
}
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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::knowledge_client::KnowledgeClient::with_interceptor(channel, mcp_auth(bearer));
let resp = c.callees(pb::KnowledgeCallQuery {
repo: args.repo.clone(), name: args.name.clone(), limit: args.limit.unwrap_or(100) as u32,
}).await.map_err(internal)?.into_inner();
return ok_json(&pb_knowledge_calls_json(resp));
}
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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::knowledge_client::KnowledgeClient::with_interceptor(channel, mcp_auth(bearer));
let resp = c.call_path(pb::KnowledgeCallPathQuery {
repo: args.repo.clone(), from: args.from.clone(), to: args.to.clone(),
}).await.map_err(internal)?.into_inner();
let v = if resp.names.is_empty() { serde_json::Value::Null } else { json!(resp.names) };
return ok_json(&v);
}
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 = "DWARF symbol lookup over the PERSISTED warehouse facts (no binary needed): function symbols for <repo> (or `_workspace`) whose demangled name contains `arg`, at the latest dwarf snapshot or the one pinned by `sha` (time-travel). Airgapped read of the historized dwarf_* tables — populate them with `introspect symbols --persist [--repo X]`.")]
async fn dwarf_symbol_lookup(
&self,
Parameters(args): Parameters<DwarfStoredArgs>,
) -> Result<CallToolResult, McpError> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::dwarf_client::DwarfClient::with_interceptor(channel, mcp_auth(bearer));
let r = c.symbol_lookup(pb::DwarfQuery {
repo: args.repo.clone(), arg: args.arg.clone(),
sha: args.sha.clone().unwrap_or_default(), limit: args.limit.unwrap_or(0) as u32,
}).await.map_err(internal)?.into_inner();
return mimir_emit(&r.json);
}
let arg = args.arg.clone();
let limit = args.limit.unwrap_or(50);
let body = self
.dwarf_load_json(args.repo, args.sha, move |facts| {
let hits: Vec<_> = facts.lookup(&arg).into_iter().take(limit).collect();
serde_json::to_string_pretty(&hits)
})
.await?;
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "DWARF defined-in over the PERSISTED warehouse facts (no binary needed): function symbols for <repo> (or `_workspace`) defined in a source file whose path ends with `arg` (e.g. `bar.rs`), at the latest dwarf snapshot or the one pinned by `sha`. Reads the historized dwarf_* tables — populate via `introspect symbols --persist`.")]
async fn dwarf_defined_in(
&self,
Parameters(args): Parameters<DwarfStoredArgs>,
) -> Result<CallToolResult, McpError> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::dwarf_client::DwarfClient::with_interceptor(channel, mcp_auth(bearer));
let r = c.defined_in(pb::DwarfQuery {
repo: args.repo.clone(), arg: args.arg.clone(),
sha: args.sha.clone().unwrap_or_default(), limit: args.limit.unwrap_or(0) as u32,
}).await.map_err(internal)?.into_inner();
return mimir_emit(&r.json);
}
let arg = args.arg.clone();
let limit = args.limit.unwrap_or(100);
let body = self
.dwarf_load_json(args.repo, args.sha, move |facts| {
let hits: Vec<_> = facts.defined_in(&arg).into_iter().take(limit).collect();
serde_json::to_string_pretty(&hits)
})
.await?;
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "DWARF callers over the PERSISTED warehouse facts (no binary needed): functions that call `arg` in <repo>, from the historized inline-call edges at the latest dwarf snapshot or the one pinned by `sha`. Inlined edges only. Populate via `introspect symbols --persist`.")]
async fn dwarf_callers(
&self,
Parameters(args): Parameters<DwarfStoredArgs>,
) -> Result<CallToolResult, McpError> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::dwarf_client::DwarfClient::with_interceptor(channel, mcp_auth(bearer));
let r = c.callers(pb::DwarfQuery {
repo: args.repo.clone(), arg: args.arg.clone(),
sha: args.sha.clone().unwrap_or_default(), limit: 0,
}).await.map_err(internal)?.into_inner();
return mimir_emit(&r.json);
}
let arg = args.arg.clone();
let body = self
.dwarf_load_json(args.repo, args.sha, move |facts| {
serde_json::to_string_pretty(&facts.callers_of(&arg))
})
.await?;
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "DWARF callees over the PERSISTED warehouse facts (no binary needed): functions called by `arg` in <repo>, from the historized inline-call edges at the latest dwarf snapshot or the one pinned by `sha`. Inlined edges only.")]
async fn dwarf_callees(
&self,
Parameters(args): Parameters<DwarfStoredArgs>,
) -> Result<CallToolResult, McpError> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::dwarf_client::DwarfClient::with_interceptor(channel, mcp_auth(bearer));
let r = c.callees(pb::DwarfQuery {
repo: args.repo.clone(), arg: args.arg.clone(),
sha: args.sha.clone().unwrap_or_default(), limit: 0,
}).await.map_err(internal)?.into_inner();
return mimir_emit(&r.json);
}
let arg = args.arg.clone();
let body = self
.dwarf_load_json(args.repo, args.sha, move |facts| {
serde_json::to_string_pretty(&facts.callees_of(&arg))
})
.await?;
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "DWARF call-path over the PERSISTED warehouse facts (no binary needed): shortest inline-call chain `from` → `to` in <repo> at the latest dwarf snapshot or the one pinned by `sha`. Returns the function-name path, or null if none.")]
async fn dwarf_call_path(
&self,
Parameters(args): Parameters<DwarfPathArgs>,
) -> Result<CallToolResult, McpError> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::dwarf_client::DwarfClient::with_interceptor(channel, mcp_auth(bearer));
let r = c.call_path(pb::DwarfPathQuery {
repo: args.repo.clone(), from: args.from.clone(), to: args.to.clone(),
sha: args.sha.clone().unwrap_or_default(),
}).await.map_err(internal)?.into_inner();
return mimir_emit(&r.json);
}
let from = args.from.clone();
let to = args.to.clone();
let body = self
.dwarf_load_json(args.repo, args.sha, move |facts| {
serde_json::to_string_pretty(&facts.call_path(&from, &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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::funnel_client::FunnelClient::with_interceptor(channel, mcp_auth(bearer));
let r = c.submit_idea(pb::SubmitIdeaRequest {
text: args.text.clone(),
source: args.source.clone().unwrap_or_else(|| "mcp".into()),
}).await.map_err(internal)?.into_inner();
return Ok(CallToolResult::success(vec![Content::text(r.id)]));
}
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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::funnel_client::FunnelClient::with_interceptor(channel, mcp_auth(bearer));
let r = c.create_plan(pb::CreatePlanRequest {
idea_id: args.idea_id.clone(),
summary: args.summary.clone(),
}).await.map_err(internal)?.into_inner();
return Ok(CallToolResult::success(vec![Content::text(r.id)]));
}
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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::funnel_client::FunnelClient::with_interceptor(channel, mcp_auth(bearer));
let r = c.add_node(pb::AddNodeRequest {
plan_id: args.plan_id.clone(),
kind: args.kind.clone(),
title: args.title.clone().unwrap_or_default(),
prompt: args.prompt.clone().unwrap_or_default(),
targets: args.targets.clone(),
needs: args.needs.clone(),
}).await.map_err(internal)?.into_inner();
return Ok(CallToolResult::success(vec![Content::text(r.id)]));
}
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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::funnel_client::FunnelClient::with_interceptor(channel, mcp_auth(bearer));
c.link(pb::LinkRequest {
plan_id: args.plan_id.clone(),
from: args.from.clone(),
to: args.to.clone(),
}).await.map_err(internal)?;
return Ok(CallToolResult::success(vec![Content::text("ok".to_string())]));
}
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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::funnel_client::FunnelClient::with_interceptor(channel, mcp_auth(bearer));
let r = c.next(pb::Empty {}).await.map_err(internal)?.into_inner();
let steps: Vec<_> = r.steps.into_iter().map(|s| json!({
"plan_id": s.plan_id,
"node_id": s.node_id,
"kind": s.kind,
"targets": s.targets,
"summary": s.summary,
"prompt_excerpt": (!s.prompt.is_empty()).then_some(s.prompt),
})).collect();
return ok_json(&steps);
}
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> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::funnel_client::FunnelClient::with_interceptor(channel, mcp_auth(bearer));
c.set_status(pb::SetStatusRequest {
plan_id: args.plan_id.clone(),
node_id: args.node_id.clone(),
status: args.status.clone(),
why: args.why.clone().unwrap_or_default(),
}).await.map_err(internal)?;
return Ok(CallToolResult::success(vec![Content::text("ok".to_string())]));
}
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> {
use std::fmt::Write;
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::funnel_client::FunnelClient::with_interceptor(channel, mcp_auth(bearer));
let dump = c.show(pb::Empty {}).await.map_err(internal)?.into_inner();
let n_plans: usize = dump.ideas.iter().map(|i| i.plans.len()).sum();
let mut out = String::new();
let _ = writeln!(out, "ideas: {}, plans: {}", dump.ideas.len(), n_plans);
for idea in &dump.ideas {
let _ = writeln!(out, " {} [{}] {}", idea.id, idea.source, idea.text);
}
for idea in &dump.ideas {
for plan in &idea.plans {
let edges: usize = plan.nodes.iter().map(|n| n.deps.len()).sum();
let _ = writeln!(
out,
" {} (idea {}) [{}] {} — {} nodes, {} edges",
plan.id, plan.idea_id, plan.status, plan.summary, plan.nodes.len(), edges,
);
for n in &plan.nodes {
let _ = writeln!(out, " {} [{}] {} {}", n.id, n.status, n.kind, n.title);
}
}
}
return Ok(CallToolResult::success(vec![Content::text(out)]));
}
let s = self.state.lock().await;
let f = &s.funnel.funnel;
let mut out = String::new();
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)?
}
async fn dwarf_load_json<F>(
&self,
repo: String,
sha: Option<String>,
f: F,
) -> Result<String, McpError>
where
F: FnOnce(&nornir::introspect::persist::DwarfFacts) -> 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 into = warehouse_root
.parent()
.unwrap_or(warehouse_root.as_path())
.join("cache/dwarf")
.join(&repo);
let facts = nornir::introspect::persist::load_dwarf(&wh, &repo, sha.as_deref(), &into)
.map_err(internal)?;
f(&facts).map_err(internal)
})
.await
.map_err(internal)?
}
}
#[tool_handler]
impl ServerHandler for NornirServer {
async fn call_tool(
&self,
request: CallToolRequestParams,
context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> Result<CallToolResult, McpError> {
let tool = request.name.to_string();
let started = std::time::Instant::now();
let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
let result = self.tool_router.call(tcc).await;
if let Some(tx) = &self.log_tx {
let _ = tx.send(McpCall {
ts_micros: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_micros() as i64)
.unwrap_or(0),
tool,
status: if result.is_ok() { "ok" } else { "err" }.to_string(),
latency_ms: started.elapsed().as_millis() as i64,
});
}
result
}
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, repo_overview, 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}, \
dwarf_{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)
}
mod pb {
tonic::include_proto!("nornir.v1");
}
type Bearer = tonic::metadata::MetadataValue<tonic::metadata::Ascii>;
fn server_target() -> Option<String> {
std::env::var("NORNIR_SERVER").ok().filter(|s| !s.is_empty())
}
fn client_workspace() -> Option<String> {
std::env::var("NORNIR_WORKSPACE").ok().filter(|s| !s.is_empty())
}
async fn mcp_connect() -> Result<(tonic::transport::Channel, Bearer), McpError> {
let server = server_target().ok_or_else(|| internal("NORNIR_SERVER is not set"))?;
let token = std::env::var("NORNIR_SERVER_TOKEN")
.map_err(|_| internal("NORNIR_SERVER is set; NORNIR_SERVER_TOKEN is required"))?;
let endpoint = if server.starts_with("http") { server } else { format!("http://{server}") };
let bearer: Bearer = format!("Bearer {token}").parse().map_err(internal)?;
let channel = tonic::transport::Channel::from_shared(endpoint.clone())
.map_err(|e| internal(format!("invalid NORNIR_SERVER url `{endpoint}`: {e}")))?
.connect()
.await
.map_err(|e| internal(format!("connect to nornir-server at {endpoint}: {e}")))?;
Ok((channel, bearer))
}
fn mcp_auth(
bearer: Bearer,
) -> impl FnMut(tonic::Request<()>) -> std::result::Result<tonic::Request<()>, tonic::Status> + Clone {
move |mut req: tonic::Request<()>| {
req.metadata_mut().insert("authorization", bearer.clone());
if let Some(ws) = client_workspace() {
if let Ok(v) = ws.parse() {
req.metadata_mut().insert("nornir-workspace", v);
}
}
Ok(req)
}
}
fn pb_guard_text(report: &pb::GuardReport) -> String {
let mut s = format!("{:<8} {:<8} {:<8} path\n", "exists", "writable", "changed");
for p in &report.paths {
s.push_str(&format!(
"{:<8} {:<8} {:<8} {}\n",
yn(p.exists), yn(p.writable), yn(p.changed), p.path
));
}
s
}
fn mimir_emit(json: &str) -> Result<CallToolResult, McpError> {
let v: serde_json::Value = serde_json::from_str(json).map_err(internal)?;
ok_json(&v)
}
fn pb_bench_run(r: pb::BenchRun) -> bench::BenchRun {
bench::BenchRun {
date: r.date,
timestamp: if r.timestamp.is_empty() { None } else { Some(r.timestamp) },
version: r.version,
machine: r.machine,
cores: r.cores,
results: r
.results
.into_iter()
.map(|res| {
let mut metrics = serde_json::Map::new();
for kv in res.metrics {
metrics.insert(kv.key, json!(kv.value));
}
bench::BenchResult { name: res.name, metrics }
})
.collect(),
tests: r
.tests
.into_iter()
.map(|t| bench::TestOutcome {
name: t.name,
passed: t.passed,
duration_ms: if t.has_duration { Some(t.duration_ms) } else { None },
message: if t.message.is_empty() { None } else { Some(t.message) },
})
.collect(),
}
}
fn pb_symbols_json(list: pb::SymbolList) -> Vec<serde_json::Value> {
list.symbols
.into_iter()
.map(|s| {
json!({
"name": s.name, "name_demangled": s.name_demangled,
"name_mangled": s.name_mangled, "file": s.file, "line": s.line,
"size_bytes": s.size_bytes, "krate": s.krate,
})
})
.collect()
}
fn pb_knowledge_symbols_json(k: pb::KnowledgeSymbols) -> Vec<serde_json::Value> {
k.symbols
.into_iter()
.map(|s| {
json!({
"crate_name": s.crate_name, "module_path": s.module_path,
"item_kind": s.item_kind, "item_name": s.item_name,
"visibility": s.visibility, "file": s.file, "line": s.line,
"doc_lines": s.doc_lines, "signature": s.signature,
})
})
.collect()
}
fn pb_knowledge_calls_json(k: pb::KnowledgeCalls) -> Vec<serde_json::Value> {
k.calls
.into_iter()
.map(|c| {
json!({
"crate_name": c.crate_name, "caller_path": c.caller_path,
"callee_ident": c.callee_ident, "call_kind": c.call_kind,
"file": c.file, "line": c.line,
})
})
.collect()
}
fn pb_gate_result(g: pb::GateResult) -> Result<CallToolResult, McpError> {
if g.status == "pass" {
let mut msg = format!("ok: {}", g.gate);
if !g.version.is_empty() {
msg.push_str(&format!(" on v{}", g.version));
}
Ok(CallToolResult::success(vec![Content::text(msg)]))
} else {
Err(internal(format!(
"gate {} failed: {}",
g.gate,
if g.message.is_empty() { "(no detail)" } else { &g.message }
)))
}
}
async fn mcp_docs_export_remote(
repo: &str,
format: Option<&str>,
book: bool,
) -> Result<CallToolResult, McpError> {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::docs_client::DocsClient::with_interceptor(channel, mcp_auth(bearer));
let req = pb::DocsExportRequest {
repo: repo.to_string(),
format: format.unwrap_or("").to_string(),
};
let r = if book {
c.book(req).await
} else {
c.export(req).await
}
.map_err(internal)?
.into_inner();
let mut body = json!({
"repo": r.repo,
"format": r.format,
"bytes": r.bytes,
"out": r.out,
"sha256": r.sha256,
"git_sha": r.git_sha,
"export_id": r.export_id,
});
if book {
body["sources"] = json!(r.sources);
}
ok_json(&body)
}
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 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()))
}
fn mcp_history(repo_root: &std::path::Path, repo: &config::Repo) -> Vec<bench::BenchRun> {
let path = repo_root.join(if repo.history.is_empty() { "bench_history.jsonl" } else { &repo.history });
bench::history::read_all(&path).unwrap_or_default()
}
#[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 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",
"repo_overview",
"docs_book",
"docs_export",
"dwarf_symbol_lookup",
"dwarf_defined_in",
"dwarf_callers",
"dwarf_callees",
"dwarf_call_path",
] {
assert!(router.has_route(name), "expected MCP tool `{name}` to be registered");
}
}
}