//! `nornir-mcp` — exposes the nornir library over the Model Context Protocol.
//!
//! Same surface as `nornir-cli`, different presentation: an MCP server
//! that registers each nornir API as a tool. LLM agents (Claude Desktop,
//! Copilot CLI, etc.) connect over stdio and can drive
//! guard/bench/release/docs/introspect operations directly.
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>,
/// Fire-and-forget sink for per-call telemetry (tool, status, latency). A
/// single background thread drains it into the warehouse `mcp_requests`
/// table; `None` if the warehouse couldn't be opened. Sending never blocks
/// or fails a tool call.
log_tx: Option<tokio::sync::mpsc::UnboundedSender<McpCall>>,
}
/// Background telemetry writer: owns one [`IcebergWarehouse`] for its lifetime
/// (its own runtime — runs on a plain thread, never nested in tokio) and
/// drain-batches incoming [`McpCall`]s into one snapshot per burst. Best-effort:
/// a warehouse error disables telemetry without touching the server.
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");
}
/// CLIENT-mode telemetry: drain-batch incoming [`McpCall`]s and submit each
/// burst to the server over `Telemetry.SubmitMcpCalls` (the server owns the
/// warehouse). Best-effort — a submit error logs to stderr and never touches a
/// tool call. Runs its own current-thread runtime on a plain OS thread.
fn spawn_mcp_log_submitter(mut rx: tokio::sync::mpsc::UnboundedReceiver<McpCall>) {
std::thread::Builder::new()
.name("nornir-mcp-submit".into())
.spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
Ok(r) => r,
Err(e) => {
eprintln!("mcp-submit: runtime build failed; telemetry off: {e}");
return;
}
};
rt.block_on(async move {
while let Some(first) = rx.recv().await {
let mut batch = vec![first];
while let Ok(more) = rx.try_recv() {
batch.push(more);
if batch.len() >= 256 {
break;
}
}
if let Err(e) = submit_mcp_calls_remote(&batch).await {
eprintln!("mcp-submit: {} call(s) not recorded: {e}", batch.len());
}
}
});
})
.expect("spawn nornir-mcp-submit thread");
}
/// Submit one batch of telemetry to the server's `mcp_requests` (the active
/// workspace via the `nornir-workspace` header `mcp_auth` stamps).
async fn submit_mcp_calls_remote(batch: &[McpCall]) -> Result<(), McpError> {
let (channel, bearer) = mcp_connect().await?;
let mut c = pb::telemetry_client::TelemetryClient::with_interceptor(channel, mcp_auth(bearer));
let calls = batch
.iter()
.map(|m| pb::McpCallPb {
ts_micros: m.ts_micros,
tool: m.tool.clone(),
status: m.status.clone(),
latency_ms: m.latency_ms,
})
.collect();
c.submit_mcp_calls(pb::SubmitMcpCallsRequest { calls }).await.map_err(internal)?;
Ok(())
}
struct State {
loaded: Loaded,
funnel: FunnelStore,
/// Lazily-built, cached dependency Mímir (graph + workspace name).
/// Built on first dep-tool call from the resolved `nornir-workspace.toml`.
mimir: Option<Arc<MimirCtx>>,
/// Lazily-loaded embedder (model load is ~1s; cached for the server life).
#[cfg(any(feature = "embed-tract", feature = "embed-ort"))]
embedder: Option<Arc<dyn nornir::vector::store::Embedder>>,
}
/// The dependency-graph mimir context, cached in [`State`] after the
/// first dep-tool call so the (cargo-metadata-backed) graph build only
/// happens once per server lifetime.
struct MimirCtx {
graph: WorkspaceGraph,
workspace_name: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
#[allow(dead_code)] // fields read only in embed-feature builds
struct VectorSearchArgs {
/// Natural-language or code query.
query: String,
/// Repo to search (embeddings are per-repo; must have been vectorized via
/// `nornir vector index <repo>`).
repo: String,
/// Pin a historical git SHA prefix for time-travel; omit for the latest.
#[serde(default)]
sha: String,
/// Max hits (default 10).
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct RepoArg {
/// Repo name as declared under `[repo.<name>]` in nornir.toml.
repo: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct TestCoverageArgs {
/// Repo whose knowledge map drives the function surface. Defaults to the
/// workspace's primary repo (the workspace name) when omitted.
#[serde(default)]
repo: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct WorkspaceUseArgs {
/// Workspace to make active (see `workspaces_list`). Empty clears the
/// override (back to `NORNIR_WORKSPACE`).
name: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct WorkspaceRegisterArgs {
/// Workspace name.
name: String,
/// Server-readable path to the workspace's `nornir-workspace.toml` descriptor.
descriptor: String,
/// `monitored` | `pushed` | `external` (default `monitored`).
#[serde(default)]
mode: String,
/// Poll interval for monitored workspaces, e.g. `60s` (empty = server default).
#[serde(default)]
poll: String,
/// Record-only opt-out. EAGER by default (false): registering clones the
/// members + builds the warehouse inline, so the returned `members` are
/// populated NOW. Set `true` to defer population to the poll loop (the old
/// record-only behaviour) — the returned `members` are then empty.
#[serde(default)]
lazy: bool,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct WorkspaceOpt {
/// Workspace name; empty = the active workspace.
#[serde(default)]
workspace: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct CratePublishedArgs {
/// Crate name to look up on crates.io (case-insensitive).
name: String,
}
/// `viz.state` takes no args — it just reads the live dump file.
#[derive(Debug, Default, serde::Deserialize, schemars::JsonSchema)]
struct VizStateArgs {}
#[derive(Debug, Default, serde::Deserialize, schemars::JsonSchema)]
struct VizClickArgs {
/// Tab to make active, by its `state_json()["tab"]` debug name. One of:
/// `Timeline`, `DepGraph`, `CallGraph`, `Funnel`, `TimeTravel`, `LiveRun`,
/// `Release`, `Knowledge`, `Warehouse`, `Mcp`, `Search`, `Gates`, `Bench`,
/// `Test`, `Leaderboard`, `Security`. Omit to leave the tab unchanged.
#[serde(default)]
tab: String,
/// Workspace to select in the picker. Omit to leave it unchanged.
#[serde(default)]
workspace: String,
/// App-wide facett palette to activate (re-skins every pane). One of:
/// `default`, `sci-fi`, `nordic-aurora`, `cyberpunk-neon`, `amber-crt`,
/// `deep-space`, `hugin-noir` (`-`/`_`/spaces interchange). Omit to leave the
/// palette unchanged.
#[serde(default)]
palette: String,
/// R6 — set a named form field on the live viz, as `"name=value"` (CLI/robot
/// parity with the in-process set_field). Recognised names:
/// `funnel.intake`, `funnel.intake_is_error`, `funnel.plan_target`,
/// `funnel.demo_size`. Omit to set no field.
#[serde(default)]
set_field: String,
/// R6 — click a named button/action on the live viz by a stable key (e.g.
/// `funnel.submit_intake`, `funnel.classify`, `funnel.generate_plan`,
/// `funnel.run_demo`). Omit to click nothing.
#[serde(default)]
click_id: String,
/// How long (ms) to wait for the running viz to consume the command before
/// reading state back (default 1200). Ignored for `apply=false`.
#[serde(default)]
wait_ms: Option<u64>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct RegressionTraceArgs {
/// Repo name as declared under `[repo.<name>]` in nornir.toml.
repo: String,
/// Restrict to one workspace name; omit/empty = every workspace.
#[serde(default)]
workspace: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct SearchArgs {
/// BM25 query (Tantivy syntax: terms, "phrases", AND/OR/NOT, +required, -excluded).
query: String,
/// Optional corpus filter: docs | code | bench_history | changelog | config.
#[serde(default)]
corpus: Option<String>,
/// Optional repo filter (top-level workspace dir, e.g. "holger").
#[serde(default)]
repo: Option<String>,
/// Max hits to return (default 10).
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct SymbolLookupArgs {
/// Path (relative to workspace root or absolute) to a debug-info binary.
binary: String,
/// Substring matched against demangled/mangled symbol names.
pattern: String,
/// Max hits to return (default 25).
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct DefinedInArgs {
/// Path (relative to workspace root or absolute) to a debug-info binary.
binary: String,
/// Source file path suffix, e.g. `nornir/src/bench/mod.rs` or `mod.rs`.
file: String,
/// Max hits to return (default 100).
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct CallQueryArgs {
/// Path (relative to workspace root or absolute) to a debug-info binary.
binary: String,
/// Demangled (generics-stripped) function name, e.g. `nornir::index::Index::build`.
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 name as declared under `[repo.<name>]` in nornir.toml.
repo: String,
/// SymbolLookup: substring matched against item names.
/// DefinedIn: source-file path suffix (e.g. `falkor.rs` or `src/index/mod.rs`).
arg: String,
/// Max hits to return (default 50 for lookup, 100 for defined-in).
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct DwarfStoredArgs {
/// Repo (or `_workspace`) the DWARF snapshot was keyed to via
/// `introspect symbols --persist`.
repo: String,
/// SymbolLookup: substring matched against the demangled name.
/// DefinedIn: source-file path suffix (e.g. `bar.rs` or `src/lib.rs`).
arg: String,
/// Git SHA to pin to (time-travel). Omit for the latest snapshot.
#[serde(default)]
sha: Option<String>,
/// Max hits to return (default 50 for lookup, 100 for defined-in).
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct DwarfPathArgs {
/// Repo (or `_workspace`) the DWARF snapshot was keyed to.
repo: String,
/// Source function (demangled name).
from: String,
/// Target function (demangled name).
to: String,
/// Git SHA to pin to (time-travel). Omit for the latest snapshot.
#[serde(default)]
sha: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct ArchArgs {
/// Repo name as declared under `[repo.<name>]` in nornir.toml.
repo: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct ArchTraceArgs {
/// Repo name as declared under `[repo.<name>]` in nornir.toml.
repo: String,
/// Entrypoint to trace from — matched against node labels/ids by
/// case-insensitive substring (`TestTab`, `Viz.Timeline`, a module bucket).
entrypoint: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct AirgapPackArgs {
/// Staging tree directory to pack (artifacts laid out as in the bundle).
staging: String,
/// Output bundle path (`*.tar.zst`).
out: String,
/// Airgap generation stamp (default: today, YYYYMMDD).
#[serde(default)]
version: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct AirgapBundleArgs {
/// The received bundle path (sidecar `<bundle>.manifest.json` must exist).
bundle: String,
/// Optional out-of-band whole-file SHA-256 to also confirm.
#[serde(default)]
sha256: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct AirgapUnfoldArgs {
/// Backend: `kvm` | `terraform` | `redfish`.
backend: String,
/// Target name / workspace / BMC url (backend-specific).
target: String,
/// KVM only: disk image to boot.
#[serde(default)]
disk: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct KnowledgeCallArgs {
/// Repo name as declared under `[repo.<name>]` in nornir.toml.
repo: String,
/// Function name (callee for callers, caller for callees).
name: String,
/// Max hits to return (default 100).
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct KnowledgeCallPathArgs {
/// Repo name as declared under `[repo.<name>]` in nornir.toml.
repo: String,
/// Source function (matched by last path segment, e.g. `run_pipeline`).
from: String,
/// Target function (matched by last path segment, e.g. `commit`).
to: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct DocsHistoryArgs {
/// Repo name as declared under `[repo.<name>]` in nornir.toml.
repo: String,
/// Restrict to one document, e.g. "README".
#[serde(default)]
doc: Option<String>,
/// Restrict to one version, e.g. "0.1.0".
#[serde(default)]
version: Option<String>,
/// Restrict to one format: pdf | html | md.
#[serde(default)]
format: Option<String>,
/// Max rows to return (default 50).
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct DocsBookArgs {
/// Repo name as declared under `[repo.<name>]` in nornir.toml.
repo: String,
/// Output format: pdf | html | md. Defaults to pdf. (md + pdf are the
/// primary paths; html is typst's experimental HTML target.)
#[serde(default)]
format: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct DocsExportArgs {
/// Repo name as declared under `[repo.<name>]` in nornir.toml.
repo: String,
/// Output format: pdf | html | md. Defaults to pdf.
#[serde(default)]
format: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct FunnelSubmitIdeaArgs {
/// One-line description of the idea.
text: String,
/// Optional source/provenance tag (e.g. "agent:claude", "user").
#[serde(default)]
source: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct FunnelCreatePlanArgs {
/// Idea id this plan refines, e.g. "i-002".
idea_id: String,
/// Short summary of the plan.
summary: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct FunnelAddNodeArgs {
/// Plan id, e.g. "p-002".
plan_id: String,
/// Verb/kind, e.g. "code:write", "test:run", "doc:update".
kind: String,
/// Optional human-readable title.
#[serde(default)]
title: Option<String>,
/// Optional prompt/notes for the executor.
#[serde(default)]
prompt: Option<String>,
/// Optional file/symbol targets the node will touch.
#[serde(default)]
targets: Vec<String>,
/// Optional list of node-ids this node depends on (in same plan).
#[serde(default)]
needs: Vec<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct FunnelLinkArgs {
/// Plan id both nodes belong to.
plan_id: String,
/// Predecessor node-id.
from: String,
/// Successor node-id (will gain `from` as a dependency).
to: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct FunnelStatusArgs {
/// Plan id, e.g. "p-002".
plan_id: String,
/// Node id, e.g. "n-013".
node_id: String,
/// One of: ready | active | blocked | done | abandoned.
status: String,
/// Optional reason (required when status=blocked or abandoned).
#[serde(default)]
why: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct DepsOfArgs {
/// Repo name as declared in the workspace descriptor / nornir.toml.
repo: String,
/// When true, return the full transitive closure instead of just
/// direct neighbours. Defaults to false.
#[serde(default)]
transitive: bool,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct AffectedArgs {
/// Repos that changed; the tool returns these plus everything that
/// transitively depends on them, in build order.
repos: Vec<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct DepPathArgs {
/// Source repo (the dependent).
from: String,
/// Target repo (the dependency) to reach via dependency edges.
to: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct ExternalCrateArgs {
/// External crate name, e.g. "serde".
#[serde(rename = "crate")]
krate: String,
}
#[tool_router]
impl NornirServer {
async fn new(loaded: Loaded) -> Result<Self> {
// Single source of truth shared with the CLI + server — see
// `FunnelStore::resolve_root`: config `[storage].local_path` >
// the home-derived `<home>/.nornir/funnel` (no path env-var).
let funnel_root = FunnelStore::resolve_root(
&loaded.workspace_root,
&loaded.nornir.storage.local_path,
None,
);
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(),
);
// Per-call telemetry → warehouse `mcp_requests`, on a background thread.
//
// The writer holds an exclusive open on the warehouse's `catalog.redb`
// for its whole lifetime. In EMBEDDED mode (no `NORNIR_SERVER`) the
// warehouse-backed tools (`repo_overview`, `knowledge_*`, `dwarf_*`,
// `docs_history`, …) must `IcebergWarehouse::open` the SAME file — and
// redb is single-writer, so a live telemetry writer would make every
// such tool fail with "Database already open. Cannot acquire lock."
// Telemetry is therefore skipped in embedded mode (and whenever
// `NORNIR_MCP_NO_TELEMETRY` is set), so embedded warehouse reads work.
// In client mode the server owns the warehouse; the MCP never opens it
// embedded, so there is nothing to log here either.
let log_tx = if std::env::var_os("NORNIR_MCP_NO_TELEMETRY").is_some() {
None
} else if server_target().is_some() {
// CLIENT mode: the server owns the warehouse lock, so we can't write
// mcp_requests locally. Submit each call's telemetry to the server
// over Telemetry.SubmitMcpCalls → it lands in the served workspace's
// mcp_requests and the viz 📞 tab shows real usage. (Before this,
// client-mode telemetry was simply dropped → viz always read "0
// calls" even when the MCP was wired and busy.)
let (log_tx, log_rx) = tokio::sync::mpsc::unbounded_channel::<McpCall>();
spawn_mcp_log_submitter(log_rx);
Some(log_tx)
} else {
// EMBEDDED mode: write the local warehouse directly (the tools that
// open it run after this; the writer drain-batches so reads work).
let (log_tx, log_rx) = tokio::sync::mpsc::unbounded_channel::<McpCall>();
spawn_mcp_log_writer(loaded.warehouse_root(), log_rx);
Some(log_tx)
};
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,
})
}
#[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();
// crates.io sparse-index path scheme.
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(name = "viz.state", description = "What is the Urðr Threads viz showing RIGHT NOW? \
Returns the running viz's full UI-state mirror (LAW #6: 'see what the user sees as \
data') — the selected workspace + picker options, the active tab + the full tab list, \
and per-tab the rendered rows/nodes/cells AND every interactive control value \
(text-field contents, dropdown selections, toggles, search queries, selected items). \
Reads the dump the viz writes to $NORNIR_VIZ_STATE (default /tmp/nornir_viz_state.json) \
each frame. Pair with viz.click to drive the UI and observe the result: this is the \
read-half of the robot-UI-tester loop.")]
async fn viz_state(
&self,
Parameters(_args): Parameters<VizStateArgs>,
) -> Result<CallToolResult, McpError> {
let path = viz_state_path();
let raw = std::fs::read_to_string(&path).map_err(|e| {
internal(format!(
"no viz state at {path}: {e}. Is `nornir viz` (urdr-threads) running? It writes \
$NORNIR_VIZ_STATE every frame."
))
})?;
let value: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| internal(format!("viz state at {path} is not valid JSON: {e}")))?;
ok_json(&value)
}
#[tool(name = "viz.click", description = "Drive the Urðr Threads viz: switch the active TAB, \
the WORKSPACE, and/or the app-wide PALETTE. Writes a one-shot command to the viz control \
channel ($NORNIR_VIZ_CMD, default /tmp/nornir_viz_cmd.json) which the running viz polls and \
applies on its next frame; then waits briefly and returns the fresh state_json so you \
see the result. `tab` is one of: Timeline, DepGraph, CallGraph, Funnel, TimeTravel, \
LiveRun, Release, Knowledge, Warehouse, Mcp, Search, Gates, Bench, Test, Leaderboard, \
Security. `palette` is one of: default, sci-fi, nordic-aurora, cyberpunk-neon, amber-crt, \
deep-space, hugin-noir (re-skins every pane). This is the drive-half of the \
robot-UI-tester loop; pair with viz.state.")]
async fn viz_click(
&self,
Parameters(args): Parameters<VizClickArgs>,
) -> Result<CallToolResult, McpError> {
let tab = args.tab.trim();
let workspace = args.workspace.trim();
let palette = args.palette.trim();
let set_field = args.set_field.trim();
let click_id = args.click_id.trim();
if tab.is_empty()
&& workspace.is_empty()
&& palette.is_empty()
&& set_field.is_empty()
&& click_id.is_empty()
{
return Err(internal(
"viz.click needs at least one of `tab`, `workspace`, `palette`, `set_field`, or `click_id`",
));
}
// R6 — parse `set_field` as `name=value` (value may itself contain `=`).
let set_field_cmd = if set_field.is_empty() {
None
} else {
let (name, value) = set_field.split_once('=').ok_or_else(|| {
internal("`set_field` must be `name=value` (e.g. funnel.intake=add dark mode)")
})?;
Some(nornir::viz::control::VizField {
name: name.trim().to_string(),
value: value.to_string(),
})
};
if !tab.is_empty() && !VIZ_TABS.contains(&tab) {
return Err(internal(format!(
"unknown tab {tab:?}; expected one of {}",
VIZ_TABS.join(", ")
)));
}
// The known facett palette names (kept in lock-step with
// `viz::facett_theme::Theme::ALL`). Validated here so a typo is rejected
// up front; the running viz also ignores an unknown palette defensively.
// Listed literally (not via the egui `Theme`) so this binary needn't pull
// in the `viz`/egui feature just to name-check a palette.
const VIZ_PALETTES: &[&str] = &[
"default", "sci-fi", "nordic-aurora", "cyberpunk-neon", "amber-crt", "deep-space", "hugin-noir",
];
if !palette.is_empty() {
let norm = palette.to_ascii_lowercase().replace(['-', ' '], "_");
if !VIZ_PALETTES.iter().any(|p| p.replace('-', "_") == norm) {
return Err(internal(format!(
"unknown palette {palette:?}; expected one of {}",
VIZ_PALETTES.join(", ")
)));
}
}
let cmd = nornir::viz::control::VizCommand {
tab: (!tab.is_empty()).then(|| tab.to_string()),
workspace: (!workspace.is_empty()).then(|| workspace.to_string()),
palette: (!palette.is_empty()).then(|| palette.to_string()),
set_field: set_field_cmd,
click_id: (!click_id.is_empty()).then(|| click_id.to_string()),
screenshot: None,
};
nornir::viz::control::write_command(&cmd)
.map_err(|e| internal(format!("write viz command to {}: {e}", nornir::viz::control::cmd_path())))?;
// Give the running viz a few frames to consume the command file (it
// removes the file once applied) before reading state back.
let wait = std::time::Duration::from_millis(args.wait_ms.unwrap_or(1200));
let deadline = std::time::Instant::now() + wait;
let cmd_path = nornir::viz::control::cmd_path();
let mut consumed = false;
while std::time::Instant::now() < deadline {
tokio::time::sleep(std::time::Duration::from_millis(60)).await;
if !std::path::Path::new(&cmd_path).exists() {
consumed = true;
break;
}
}
let state_path = viz_state_path();
let state = std::fs::read_to_string(&state_path)
.ok()
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok());
ok_json(&serde_json::json!({
"command": { "tab": cmd.tab, "workspace": cmd.workspace },
"command_path": cmd_path,
// false ⇒ no live viz consumed the command within wait_ms (e.g. the
// viz isn't running). The command file still sits there for whenever
// a viz next polls; nothing is lost.
"consumed": consumed,
"state": state,
}))
}
#[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 mut names: Vec<String> = resp.repos.into_iter().map(|r| r.name).collect();
// Monitored workspaces keep their repos as registry *members*, not in
// the (synthetic) config Repos.List reads — fall back to those.
if names.is_empty() {
if let Some(ws) = client_workspace() {
let (ch2, b2) = mcp_connect().await?;
let mut wc = pb::workspaces_client::WorkspacesClient::with_interceptor(ch2, mcp_auth(b2));
if let Ok(rec) = wc.get(pb::WorkspaceName { name: ws }).await {
names = rec.into_inner().members.into_iter().map(|m| m.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 = "List EVERY workspace this MCP can target (server mode) WITH its repos — the \
full workspace×repo surface in one call, plus the active workspace. One MCP \
serves every registered workspace; `workspace_use <name>` switches focus \
without a restart. No NORNIR_WORKSPACE pin required (it's an optional default)."
)]
async fn workspaces_list(&self) -> Result<CallToolResult, McpError> {
if server_target().is_some() {
let (channel, bearer) = mcp_connect().await?;
let mut c =
pb::workspaces_client::WorkspacesClient::with_interceptor(channel, mcp_auth(bearer));
let resp = c.list(pb::Empty {}).await.map_err(internal)?.into_inner();
// Enrich each workspace with its repo members (Workspaces.Get) so the
// agent sees ALL repos across ALL workspaces without switching first.
let mut workspaces = Vec::with_capacity(resp.workspaces.len());
for w in resp.workspaces {
let (ch, b) = mcp_connect().await?;
let mut wc =
pb::workspaces_client::WorkspacesClient::with_interceptor(ch, mcp_auth(b));
let repos: Vec<String> = match wc.get(pb::WorkspaceName { name: w.name.clone() }).await {
Ok(rec) => rec.into_inner().members.into_iter().map(|m| m.name).collect(),
Err(_) => Vec::new(),
};
workspaces.push(serde_json::json!({ "name": w.name, "repos": repos }));
}
return ok_json(&serde_json::json!({
"active": client_workspace(),
"workspaces": workspaces,
}));
}
ok_json(&serde_json::json!({
"active": client_workspace(),
"note": "embedded mode (no NORNIR_SERVER) serves one workspace; set NORNIR_SERVER for multi-workspace switching",
}))
}
#[tool(
description = "Switch the active workspace for subsequent tool calls (server mode) — no \
restart. Every repos_list / deps_of / search / repo_overview / … then targets \
<name>. Closes the multi-workspace gap: one MCP serves knut, korp, znippy, \
skade, … without relaunching. Empty name clears the override."
)]
async fn workspace_use(
&self,
Parameters(args): Parameters<WorkspaceUseArgs>,
) -> Result<CallToolResult, McpError> {
if server_target().is_some() && !args.name.is_empty() {
let (channel, bearer) = mcp_connect().await?;
let mut c =
pb::workspaces_client::WorkspacesClient::with_interceptor(channel, mcp_auth(bearer));
let resp = c.list(pb::Empty {}).await.map_err(internal)?.into_inner();
let known: Vec<String> = resp.workspaces.into_iter().map(|w| w.name).collect();
if !known.contains(&args.name) {
return Err(internal(format!("unknown workspace `{}`; known: {known:?}", args.name)));
}
}
set_active_workspace(&args.name);
// The cached embedded graph was built for the previous workspace.
self.state.lock().await.mimir = None;
ok_json(&serde_json::json!({ "active": client_workspace() }))
}
#[tool(
description = "Register a workspace so nornir starts tracking its git members + rebuilding \
the warehouse on change (server mode). `descriptor` = a server-readable path \
to the workspace's nornir-workspace.toml. `mode` defaults to `monitored` \
(polled). EAGER by default: the server clones the members + builds the \
warehouse INLINE, so the returned `members` are populated NOW + a `snapshot` \
is built — no waiting for a poll tick. Set `lazy: true` to record only (the \
members come back empty, populated later by the poll loop). The single \
biggest agent write-side gap, closed."
)]
async fn workspace_register(
&self,
Parameters(args): Parameters<WorkspaceRegisterArgs>,
) -> Result<CallToolResult, McpError> {
let (channel, bearer) = mcp_connect().await?;
let mut c =
pb::workspaces_client::WorkspacesClient::with_interceptor(channel, mcp_auth(bearer));
let mode = if args.mode.is_empty() { "monitored".to_string() } else { args.mode };
let rec = c
.register(pb::RegisterWorkspaceRequest {
name: args.name,
descriptor: args.descriptor,
mode,
poll: args.poll,
lazy: args.lazy,
})
.await
.map_err(internal)?
.into_inner();
ok_json(&serde_json::json!({
"registered": rec.name,
"mode": rec.mode,
"eager": !args.lazy,
"snapshot": rec.current_snapshot,
"members": rec.members.into_iter().map(|m| m.name).collect::<Vec<_>>(),
}))
}
#[tool(
description = "Push-to-rebuild: force an immediate git fetch + warehouse republish for a \
workspace right now, without waiting for the poll loop. Returns what changed \
+ the new snapshot. `workspace` empty = the active one. Use after you push."
)]
async fn sync_now(
&self,
Parameters(args): Parameters<WorkspaceOpt>,
) -> Result<CallToolResult, McpError> {
let name = if args.workspace.is_empty() { client_workspace().unwrap_or_default() } else { args.workspace };
let (channel, bearer) = mcp_connect().await?;
let mut c =
pb::workspaces_client::WorkspacesClient::with_interceptor(channel, mcp_auth(bearer));
let rep = c
.fetch(pb::WorkspaceFetchRequest { name: name.clone(), force: true, background: false })
.await
.map_err(internal)?
.into_inner();
ok_json(&serde_json::json!({
"workspace": name,
"fetched": rep.fetched,
"changed": rep.changed,
"snapshot": rep.snapshot,
"errors": rep.errors,
}))
}
#[tool(
description = "Index/freshness status: the workspace's current warehouse snapshot + each git \
member's last-seen SHA, last-synced time, sync state, and AUT6 working-tree \
freshness — so an agent can tell whether the cached knowledge is fresh before \
relying on it. A commit SHA cannot see uncommitted edits; the working-tree \
`digest` can. Each member carries the server clone's `worktree_dirty` + \
`worktree_digest`, and — when a LOCAL source for that member is reachable — a \
`staleness` signal comparing the live local tree against the indexed digest \
(`fresh` | `local_uncommitted` | `server_dirty`). `workspace` empty = the active one."
)]
async fn index_status(
&self,
Parameters(args): Parameters<WorkspaceOpt>,
) -> Result<CallToolResult, McpError> {
let name = if args.workspace.is_empty() { client_workspace().unwrap_or_default() } else { args.workspace };
let (channel, bearer) = mcp_connect().await?;
let mut c =
pb::workspaces_client::WorkspacesClient::with_interceptor(channel, mcp_auth(bearer));
let rec = c.get(pb::WorkspaceName { name: name.clone() }).await.map_err(internal)?.into_inner();
// Resolve the local descriptor (best-effort) so a thin client with a
// checked-out source can compute a LIVE working-tree digest per member
// and compare it to the server's indexed one. Remote-only clients (no
// local source) simply trust the server's clean/dirty report.
let local_paths: std::collections::BTreeMap<String, std::path::PathBuf> = {
use nornir::workspace::descriptor::RepoSource;
let s = self.state.lock().await;
resolve_workspace_descriptor(&s.loaded)
.ok()
.and_then(|p| WorkspaceDescriptor::load(&p).ok())
.map(|desc| {
desc.repos
.iter()
.filter_map(|(n, spec)| match spec.source(&desc.descriptor_dir) {
Ok(RepoSource::Path(root)) if root.exists() => Some((n.clone(), root)),
_ => None,
})
.collect()
})
.unwrap_or_default()
};
let members = rec
.members
.into_iter()
.map(|m| {
// Server clone's indexed freshness (what the warehouse was built from).
let server_clean = m.worktree_digest == nornir::gitio::CLEAN_WORKTREE_DIGEST
|| (!m.worktree_dirty && m.worktree_digest.is_empty());
// Live local digest, if this member has a reachable local checkout.
let local = local_paths
.get(&m.name)
.and_then(|root| nornir::gitio::worktree_freshness(root).ok());
// Staleness signal (advisory):
// local_uncommitted — local tree differs from the indexed digest
// server_dirty — no local source, but the server clone is dirty
// fresh — agrees / clean
let (staleness, advisory) = match &local {
Some(f) if f.dirty && !m.worktree_digest.is_empty() && f.digest != m.worktree_digest => (
"local_uncommitted",
Some(format!(
"indexed @ {}, local working tree has uncommitted changes — advisory",
&m.last_seen_sha[..m.last_seen_sha.len().min(12)]
)),
),
Some(f) if f.dirty => (
"local_uncommitted",
Some("local working tree has uncommitted changes — advisory".to_string()),
),
Some(_) => ("fresh", None),
None if m.worktree_dirty => (
"server_dirty",
Some("server clone has uncommitted changes — advisory".to_string()),
),
None if server_clean => ("fresh", None),
None => ("unknown", None),
};
serde_json::json!({
"name": m.name,
"last_seen_sha": m.last_seen_sha,
"last_synced": m.last_synced,
"sync_state": m.sync_state,
"worktree_digest": m.worktree_digest,
"worktree_dirty": m.worktree_dirty,
"local_worktree_digest": local.as_ref().map(|f| f.digest.clone()),
"local_worktree_dirty": local.as_ref().map(|f| f.dirty),
"staleness": staleness,
"advisory": advisory,
})
})
.collect::<Vec<_>>();
ok_json(&serde_json::json!({
"workspace": rec.name,
"current_snapshot": rec.current_snapshot,
"updated_at": rec.updated_at,
"members": members,
}))
}
#[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 static SVG \
flowchart (edges labelled with the justifying crate names; no JavaScript, \
no build step). Useful for a human/agent to visualise the whole workspace \
at once."
)]
async fn dep_graph_svg(&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.svg(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::svg(&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()
};
// `IcebergWarehouse::open` (and the async release-history scan) drive
// their OWN runtime via `wh.block_on`; opening one on the rmcp async
// worker would panic with "Cannot start a runtime from within a
// runtime". Run it off-thread, the same as the knowledge/dwarf tools.
let change = tokio::task::spawn_blocking(move || -> anyhow::Result<_> {
let wh = IcebergWarehouse::open(&warehouse_root)
.with_context(|| format!("open warehouse at {}", warehouse_root.display()))?;
wh.block_on(change::detect(&wh, &mimir.graph, &mimir.workspace_name))
})
.await
.map_err(internal)?
.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);
}
// Best-effort dependency graph (cached MimirCtx) for suspect ranking.
let mimir = self.mimir().await.ok();
let warehouse_root = {
let s = self.state.lock().await;
s.loaded.warehouse_root()
};
// Off-thread: opening the warehouse + the async lineage scan drive the
// warehouse's own runtime (`wh.block_on`); doing that on the rmcp async
// worker panics ("runtime within a runtime"). Same fix as the
// knowledge/dwarf/docs_history tools.
let workspace = args.workspace.clone();
let repo = args.repo.clone();
let trace = tokio::task::spawn_blocking(move || -> anyhow::Result<_> {
let wh = IcebergWarehouse::open(&warehouse_root)
.with_context(|| format!("open warehouse at {}", warehouse_root.display()))?;
let graph = mimir.as_ref().map(|m| &m.graph);
wh.block_on(nornir::release::regression::trace_gate_async(
&wh, &workspace, &repo, graph,
))
})
.await
.map_err(internal)?
.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> {
// Client mode: the server embeds + scans (it must carry an embedder).
// Relayed regardless of whether THIS binary has an embedder feature.
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);
// The embedder forward + warehouse scan both block (and the store
// drives its own runtime via block_on), so run off the async
// worker thread to avoid "runtime within a runtime".
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;
// Validate the repo exists, then resolve the warehouse root.
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)),
};
// Opening the warehouse drives its own runtime (block_on), so run off
// the async worker thread to avoid "runtime within a runtime".
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> {
// Client mode: the server renders (it must carry the docs-export feature).
if server_target().is_some() {
return mcp_docs_export_remote(&args.repo, args.format.as_deref(), true).await;
}
// The tool is always registered (the #[tool_router] macro references it
// unconditionally); only the body needs the typst-backed `docs-export`
// feature, so a lean MCP build still advertises it and returns a clear
// error rather than failing to compile.
#[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")]
{
// Gather everything owned under the lock, then drop it: render +
// typst export + warehouse write are all blocking work and the
// warehouse drives its own runtime, so do them off the async worker
// thread (avoids "runtime within a runtime") and don't hold the
// state mutex across the heavy CPU/IO.
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);
// Render managed artifacts first so the book reflects the latest source.
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();
// Current artifact → <repo>/docs/book.<ext> (live copy).
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)?;
// History → iceberg `doc_exports` (inline bytes, dedup by sha256).
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> {
// Client mode: the server renders (it must carry the docs-export feature).
if server_target().is_some() {
return mcp_docs_export_remote(&args.repo, args.format.as_deref(), false).await;
}
// Tool is always registered (the #[tool_router] macro references it
// unconditionally); only the body needs the typst-backed `docs-export`
// feature, so a lean MCP build still advertises it and returns a clear
// error rather than failing to compile.
#[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")]
{
// Gather everything owned under the lock, then drop it: render +
// typst export + warehouse write are blocking and the warehouse
// drives its own runtime, so run off the async worker thread
// (avoids "runtime within a runtime") without holding the mutex.
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);
// Render managed artifacts first so the export reflects latest source.
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();
// Current artifact → <repo>/docs/README.<ext> (live, link-target copy).
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)?;
// History → iceberg `doc_exports` (inline bytes, dedup by sha256).
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 = "autonom completeness gate (n-005): discover the testable surface (viz tabs × {thin,fat}, CLI subcommands, MCP tools, and unreached functions = symbol_facts − test-reachable(call_edges)), difference it against the covered set + the checked-in .nornir/autonom-allow.toml, and return JSON {total,covered,allowlisted,gap,green,missing[]}. green ⟺ every surface is wired (or excused by a live allowlist entry). Reads the LATEST persisted gate run; if none exists it computes one live. Optional `repo` (defaults to the workspace name).")]
async fn test_coverage(
&self,
Parameters(args): Parameters<TestCoverageArgs>,
) -> Result<CallToolResult, McpError> {
if server_target().is_some() {
return Err(McpError::invalid_params(
"test_coverage is an embedded/local tool (it reads the warehouse \
surface_coverage table directly) — run nornir-mcp without NORNIR_SERVER, \
or use the `nornir test coverage` CLI on the server host"
.to_string(),
None,
));
}
let (warehouse_root, workspace, repo, repo_root) = {
let s = self.state.lock().await;
let workspace = s.loaded.workspace_name();
let repo = args.repo.clone().unwrap_or_else(|| workspace.clone());
let repo_root = s.loaded.nornir.repo_dir_for(&s.loaded.workspace_root, &repo);
(s.loaded.warehouse_root(), workspace, repo, repo_root)
};
let value = tokio::task::spawn_blocking(move || -> anyhow::Result<serde_json::Value> {
let wh = IcebergWarehouse::open(&warehouse_root)?;
// Prefer the latest persisted run; if there is none, compute live so a
// caller always gets a real verdict (never a silent empty).
let summary = nornir::autonom::read_latest_summary(&wh, &workspace)?;
if summary.total > 0 {
return Ok(summary.to_json());
}
let allowlist =
nornir::autonom::load_allowlist(&nornir::autonom::allowlist_path(&repo_root))?;
let inputs = nornir::autonom::nornir_surface_inputs(Vec::new());
let run_id = nornir::warehouse::test_results::new_run_id();
let state =
nornir::autonom::gather(&wh, &workspace, &repo, &inputs, allowlist, &run_id)?;
nornir::autonom::persist(&wh, &state, &run_id, &workspace)?;
let rows = nornir::autonom::read_run(&wh, &run_id)?;
Ok(nornir::warehouse::surface_coverage::CoverageSummary::from_rows(&rows).to_json())
})
.await
.map_err(internal)?
.map_err(internal)?;
ok_json(&value)
}
#[tool(description = "Release: the autonom completeness gate (n-005) for <repo>'s workspace — fails when the discovered surface has any uncovered+un-allowlisted node OR any stale allowlist entry (HARD zero). Returns {ok} on green, errors with the gap/stale list on red. Embedded/local only.")]
async fn release_gate_coverage(
&self,
Parameters(args): Parameters<RepoArg>,
) -> Result<CallToolResult, McpError> {
if server_target().is_some() {
return Err(McpError::invalid_params(
"release_gate_coverage is an embedded/local tool — run nornir-mcp without \
NORNIR_SERVER, or use `nornir release gate coverage` on the server host"
.to_string(),
None,
));
}
let (warehouse_root, workspace, repo, repo_root) = {
let s = self.state.lock().await;
let workspace = s.loaded.workspace_name();
let repo_root = s.loaded.nornir.repo_dir_for(&s.loaded.workspace_root, &args.repo);
(s.loaded.warehouse_root(), workspace, args.repo.clone(), repo_root)
};
let summary = tokio::task::spawn_blocking(move || -> anyhow::Result<String> {
let wh = IcebergWarehouse::open(&warehouse_root)?;
let allowlist =
nornir::autonom::load_allowlist(&nornir::autonom::allowlist_path(&repo_root))?;
let inputs = nornir::autonom::nornir_surface_inputs(Vec::new());
let run_id = nornir::warehouse::test_results::new_run_id();
let state =
nornir::autonom::gather(&wh, &workspace, &repo, &inputs, allowlist, &run_id)?;
nornir::autonom::persist(&wh, &state, &run_id, &workspace)?;
release::gate::coverage_gate(&state.report)?;
Ok(state.report.summary())
})
.await
.map_err(internal)?
.map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(format!("ok: {summary}"))]))
}
#[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();
// Empty names = no path; mirror the embedded `null`.
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)]))
}
// ---------------- knowledge (syn symbol/call graph, no binary) --------
#[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 = "AIRGAP (Skíðblaðnir): pack a staging tree into a signed `tar.zst` airgap bundle \
+ a content-addressed sidecar manifest (per-entry SHA-256 + a root hash) + a `VERSION` file beside \
the bundle. CLI parity with `nornir airgap pack`. Returns the bundle path, entry count, and root \
SHA-256. Pure + offline.")]
async fn airgap_pack(
&self,
Parameters(args): Parameters<AirgapPackArgs>,
) -> Result<CallToolResult, McpError> {
use nornir_airgap::archive::{Archive, TarZstd};
let staging = std::path::PathBuf::from(&args.staging);
let out = std::path::PathBuf::from(&args.out);
let version = args
.version
.unwrap_or_else(|| chrono::Utc::now().format("%Y%m%d").to_string());
let body = tokio::task::spawn_blocking(move || -> Result<String, McpError> {
let manifest = TarZstd::new().pack(&staging, &version, &out).map_err(internal)?;
nornir_airgap::pack::write_version_file(&out, &version).map_err(internal)?;
Ok(format!(
"✓ packed {} entries → {} (root sha256 {})",
manifest.entries.len(),
out.display(),
manifest.root_sha256
))
})
.await
.map_err(internal)??;
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "AIRGAP (Skíðblaðnir): verify a received airgap bundle against its sidecar manifest \
— recomputes the manifest root hash AND every entry's content SHA-256 by extracting it, refusing a \
corrupt/tampered bundle. Zero network (the airgap law). CLI parity with `nornir airgap fetch`. \
Returns the verify report.")]
async fn airgap_verify(
&self,
Parameters(args): Parameters<AirgapBundleArgs>,
) -> Result<CallToolResult, McpError> {
use nornir_airgap::archive::TarZstd;
let bundle = std::path::PathBuf::from(&args.bundle);
let sha = args.sha256.clone();
let body = tokio::task::spawn_blocking(move || -> Result<String, McpError> {
let report = nornir_airgap::fetch::verify_on_receipt(&bundle, &TarZstd::new(), sha.as_deref())
.map_err(internal)?;
Ok(format!(
"{} manifest_root_ok={} bundle_hash_ok={} bad_entries={:?}",
if report.is_ok() { "✓ verified" } else { "✗ FAILED" },
report.manifest_root_ok,
report.bundle_hash_ok,
report.bad_entries
))
})
.await
.map_err(internal)??;
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "AIRGAP (Skíðblaðnir): the unfold provisioning PLAN for a target — `kvm` returns the \
qemu argv (tunnr-identical q35/accel=kvm:tcg), `terraform` returns the `nornir_component` HCL, \
`redfish` returns the ComputerSystem.Reset body. CLI parity with `nornir airgap unfold`. The actual \
provisioning (Redfish/Terraform) is a feature-gated seam; the plan is real.")]
async fn airgap_unfold_plan(
&self,
Parameters(args): Parameters<AirgapUnfoldArgs>,
) -> Result<CallToolResult, McpError> {
use nornir_airgap::unfold::{kvm::KvmTarget, redfish, terraform};
let body = match args.backend.as_str() {
"kvm" => {
let t = KvmTarget::new(&args.target, args.disk.clone().unwrap_or_default());
format!("qemu-system-x86_64 {}", t.qemu_argv().join(" "))
}
"terraform" => terraform::render_resource(&terraform::TerraformTarget {
workspace: args.target.clone(),
product: args.target.clone(),
}),
"redfish" => format!("redfish reset body: {}", redfish::reset_body()),
other => return Err(internal(anyhow::anyhow!("unknown backend `{other}` (kvm|terraform|redfish)"))),
};
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "AIRGAP (Skíðblaðnir): read back the `airgap_events` bring-up DAG from the warehouse \
JSONL sink (one row per product×op×phase, with depends_on edges). CLI parity with `nornir airgap \
events`. Returns the events or a hint when none have been written.")]
async fn airgap_events(&self) -> Result<CallToolResult, McpError> {
let warehouse_root = {
let s = self.state.lock().await;
s.loaded.warehouse_root()
};
let body = tokio::task::spawn_blocking(move || -> Result<String, McpError> {
let path = warehouse_root.join("airgap_events.jsonl");
if !path.exists() {
return Ok(format!("(no airgap_events at {} — run `nornir airgap start`)", path.display()));
}
let rows = nornir_airgap::events::read_jsonl(&path).map_err(internal)?;
serde_json::to_string_pretty(&rows).map_err(internal)
})
.await
.map_err(internal)??;
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "Architecture wiring (EPIC ARCH): the latest historized component/gRPC/core/table \
wiring graph for <repo>, rendered as a static skade circuit-board SVG (no JavaScript, no build \
step). This is the code-derived picture of which UI pane / gRPC verb / core fn reads/writes which \
warehouse table — the auto-generated version of `.nornir/architecture-wiring.md` §2. Generate it \
first with `nornir arch generate <repo>`. Returns the SVG markup, or a hint if none is historized.")]
async fn arch_svg(
&self,
Parameters(args): Parameters<ArchArgs>,
) -> Result<CallToolResult, McpError> {
let warehouse_root = {
let s = self.state.lock().await;
s.loaded.warehouse_root()
};
let repo = args.repo.clone();
let svg = 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 rows = nornir::arch::warehouse::list_arch_wiring(&wh, &repo).map_err(internal)?;
let Some(latest) = rows.first() else {
return Ok(format!(
"(no historized architecture wiring for `{repo}` — run `nornir arch generate {repo}`)"
));
};
let (_g, svg) = nornir::arch::warehouse::load_arch_wiring(&wh, &latest.wiring_id)
.map_err(internal)?
.ok_or_else(|| internal(anyhow::anyhow!("wiring row vanished")))?;
Ok(svg)
})
.await
.map_err(internal)??;
Ok(CallToolResult::success(vec![Content::text(svg)]))
}
#[tool(description = "Architecture wiring (EPIC ARCH): the latest historized component/gRPC/core/table \
wiring graph for <repo> as JSON ({nodes:[{id,label,kind}], edges:[{from,to,kind}]}). The \
code-derived quotient of the call graph + the fn→table access fact — nodes are Component (viz \
pane) / Grpc verb / CoreFn module / Table; edges are calls/reads/writes. Generate it first with \
`nornir arch generate <repo>`. Returns {} if none is historized.")]
async fn arch_graph(
&self,
Parameters(args): Parameters<ArchArgs>,
) -> Result<CallToolResult, McpError> {
let warehouse_root = {
let s = self.state.lock().await;
s.loaded.warehouse_root()
};
let repo = args.repo.clone();
let body = 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 rows = nornir::arch::warehouse::list_arch_wiring(&wh, &repo).map_err(internal)?;
let Some(latest) = rows.first() else {
return Ok("{}".to_string());
};
let (g, _svg) = nornir::arch::warehouse::load_arch_wiring(&wh, &latest.wiring_id)
.map_err(internal)?
.ok_or_else(|| internal(anyhow::anyhow!("wiring row vanished")))?;
serde_json::to_string_pretty(&g).map_err(internal)
})
.await
.map_err(internal)??;
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "Architecture wiring TRACE (EPIC ARCH/AUT7): from the latest historized wiring \
graph for <repo>, the focused sub-board of everything reachable downstream from <entrypoint> \
(a UI pane like `TestTab`, a gRPC verb like `Viz.Timeline`, or a `crate::module` bucket; matched \
by case-insensitive substring), rendered as a skade circuit-board SVG. The visual twin of \
knowledge_call_path lifted to the coarsened board — answers 'open this surface, which gRPC verbs \
/ core modules / warehouse tables does it reach?'. Generate the wiring first with `nornir arch \
generate <repo>`. Returns the SVG, or a hint when nothing matches / none is historized.")]
async fn arch_trace(
&self,
Parameters(args): Parameters<ArchTraceArgs>,
) -> Result<CallToolResult, McpError> {
let warehouse_root = {
let s = self.state.lock().await;
s.loaded.warehouse_root()
};
let repo = args.repo.clone();
let entrypoint = args.entrypoint.clone();
let svg = 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 rows = nornir::arch::warehouse::list_arch_wiring(&wh, &repo).map_err(internal)?;
let Some(latest) = rows.first() else {
return Ok(format!(
"(no historized architecture wiring for `{repo}` — run `nornir arch generate {repo}`)"
));
};
let (g, _svg) = nornir::arch::warehouse::load_arch_wiring(&wh, &latest.wiring_id)
.map_err(internal)?
.ok_or_else(|| internal(anyhow::anyhow!("wiring row vanished")))?;
let sub = nornir::arch::trace_from(&g, &entrypoint);
if sub.nodes.is_empty() {
return Ok(format!(
"(no node matched entrypoint `{entrypoint}` in the wiring graph for `{repo}`)"
));
}
Ok(sub.to_svg())
})
.await
.map_err(internal)??;
Ok(CallToolResult::success(vec![Content::text(svg)]))
}
#[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)]))
}
// ---------------- funnel (idea -> plan -> node -> run) ----------------
#[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()),
item_kind: String::new(), // MCP idea-submit always an idea
}).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(),
item_kind: nornir::funnel::ItemKind::Idea,
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();
// Re-emit the SAME shape the embedded path produces (topo::NextStep).
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, // closest legal status
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)]))
}
}
/// Dependency-mimir support (non-tool helpers).
impl NornirServer {
/// Return the cached dependency Mímir, building it on first use from
/// the resolved `nornir-workspace.toml`. The graph build runs
/// cargo-metadata per repo, so it is done once and cached in `State`.
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());
}
}
// Model load is blocking + ~1s; do it off the async lock.
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)
}
/// Open the warehouse on-demand, load the latest knowledge snapshot for
/// `repo`, and run `f` to render the result as JSON. All blocking work
/// (warehouse open + iceberg scan, both of which drive their own runtime)
/// runs on a blocking thread; the state lock is released before spawning.
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)?
}
/// Load persisted DWARF facts for `repo` (latest, or pinned to `sha`) from the
/// warehouse and run `f` over them. Restores into the workspace's
/// `cache/dwarf/<repo>` materialized view; needs no binary. Mirrors
/// [`knowledge_query_json`](Self::knowledge_query_json).
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 {
/// Central tool dispatch — wraps the macro router with per-call telemetry
/// (tool name, ok/err, latency) fired to the warehouse logger. Providing
/// `call_tool` here makes `#[tool_handler]` skip generating its own.
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_svg, 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)
}
/// Path of the viz's live UI-state dump (`$NORNIR_VIZ_STATE`, default
/// `/tmp/nornir_viz_state.json`). The viz writes its full `state_json()` here
/// every frame; `viz.state`/`viz.click` read it back. Sibling of the action-log
/// file at `$NORNIR_VIZ_ACTIONLOG`.
fn viz_state_path() -> String {
std::env::var("NORNIR_VIZ_STATE").unwrap_or_else(|_| "/tmp/nornir_viz_state.json".to_string())
}
/// The tab names `viz.click` accepts — the `state_json()["tab"]` debug names, in
/// header order. Kept in lockstep with `viz::app::Tab::ALL`.
const VIZ_TABS: [&str; 17] = [
"Nornir", "Timeline", "DepGraph", "CallGraph", "Funnel", "TimeTravel", "LiveRun", "Release",
"Knowledge", "Warehouse", "Mcp", "Search", "Gates", "Bench", "Test", "Leaderboard",
"Security",
];
// ---- client mode (talk to a running nornir-server over gRPC) ---------------
//
// Like the CLI: when `NORNIR_SERVER` is set, every warehouse-touching tool
// routes to the server (which owns the warehouse's write lock, so the MCP can't
// open it embedded). `NORNIR_SERVER_TOKEN` carries the bearer; `NORNIR_WORKSPACE`
// selects the workspace via the `nornir-workspace` header. Unset → embedded.
mod pb {
tonic::include_proto!("nornir.v1");
}
/// gRPC bearer + workspace interceptor (mirrors the CLI's `auth_interceptor`).
type Bearer = tonic::metadata::MetadataValue<tonic::metadata::Ascii>;
fn server_target() -> Option<String> {
std::env::var("NORNIR_SERVER").ok().filter(|s| !s.is_empty())
}
/// The active workspace for proxied (`NORNIR_SERVER`) calls — settable at runtime
/// by the `workspace_use` tool so one MCP hops workspaces without a restart.
/// Falls back to `NORNIR_WORKSPACE` when unset.
static ACTIVE_WS: std::sync::RwLock<Option<String>> = std::sync::RwLock::new(None);
fn set_active_workspace(name: &str) {
if let Ok(mut g) = ACTIVE_WS.write() {
*g = (!name.is_empty()).then(|| name.to_string());
}
}
fn client_workspace() -> Option<String> {
if let Ok(g) = ACTIVE_WS.read() {
if let Some(ws) = g.as_ref().filter(|s| !s.is_empty()) {
return Some(ws.clone());
}
}
std::env::var("NORNIR_WORKSPACE").ok().filter(|s| !s.is_empty())
}
/// Choose a default active workspace from `(name, repo_count)` pairs: the RICHEST
/// (most repos — the "bundle everything" workspace), ties broken alphabetically.
/// Pure so it's unit-testable; [`pick_default_workspace`] feeds it live data.
fn richest_workspace(ws: &[(String, usize)]) -> Option<String> {
ws.iter()
.max_by(|a, b| a.1.cmp(&b.1).then_with(|| b.0.cmp(&a.0)))
.map(|(name, _)| name.clone())
}
/// Auto-select a default active workspace for client mode when the user pinned
/// none (no `NORNIR_WORKSPACE`, no `workspace_use`): query the served workspaces
/// and pick the richest, so a fresh agent's knowledge/search tools return DATA
/// instead of hitting the server's bare default (the empty-`[]` trap). `None` if
/// the server serves nothing or is unreachable. Best-effort: callers log + carry on.
async fn pick_default_workspace() -> Option<String> {
let (channel, bearer) = mcp_connect().await.ok()?;
let mut c =
pb::workspaces_client::WorkspacesClient::with_interceptor(channel, mcp_auth(bearer));
let resp = c.list(pb::Empty {}).await.ok()?.into_inner();
let mut counts: Vec<(String, usize)> = Vec::with_capacity(resp.workspaces.len());
for w in resp.workspaces {
let (ch, b) = mcp_connect().await.ok()?;
let mut wc = pb::workspaces_client::WorkspacesClient::with_interceptor(ch, mcp_auth(b));
let n = wc
.get(pb::WorkspaceName { name: w.name.clone() })
.await
.map(|r| r.into_inner().members.len())
.unwrap_or(0);
counts.push((w.name, n));
}
richest_workspace(&counts)
}
/// Connect to `NORNIR_SERVER` and return a ready channel + bearer. Errors as an
/// `McpError` so callers can `?` inside a tool. Only called when `server_target`
/// is `Some`.
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))
}
/// Build the tonic interceptor that stamps the bearer + workspace headers onto
/// every request (same contract as the CLI's `auth_interceptor`).
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)
}
}
/// Render a server `GuardReport` as the same text table the embedded path emits.
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
}
/// Re-emit a JSON string returned by a Mimir RPC as a pretty tool result
/// (parses then re-serializes so client output matches the embedded `ok_json`).
fn mimir_emit(json: &str) -> Result<CallToolResult, McpError> {
let v: serde_json::Value = serde_json::from_str(json).map_err(internal)?;
ok_json(&v)
}
/// Rebuild a native `bench::BenchRun` from its protobuf form so the client path
/// serializes byte-identically to the embedded `bench_history` (which dumps
/// `Vec<bench::BenchRun>` via serde).
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(),
}
}
/// Map a server `SymbolList` (DWARF/introspect symbols) to the JSON array the
/// embedded introspect tools emit.
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()
}
/// Map server `KnowledgeSymbols` to a JSON array.
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()
}
/// Map server `KnowledgeCalls` to a JSON array.
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()
}
/// Turn a single-gate `GateResult` into the MCP result the embedded gate emits:
/// `ok: …` text on pass, an `McpError` carrying the gate message on fail.
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 }
)))
}
}
/// Client-mode `docs_export` / `docs_book`: relay to the server's `Docs.Export`
/// (`book=false`) or `Docs.Book` (`book=true`) RPC and re-emit the same JSON the
/// embedded path produces. The server owns the warehouse + the typst engine; the
/// artifact is written under the server's `<repo>/docs/`.
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)
}
/// Serialize a value to pretty JSON and wrap it as a successful tool result.
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)]))
}
/// Resolve the `nornir-workspace.toml` describing the repos to graph.
/// Honors `NORNIR_WORKSPACE` (explicit path) first, then searches a few
/// conventional locations relative to the loaded nornir.toml.
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()))
}
/// All bench runs for `repo` from the on-disk JSONL receipt, for the
/// `bench_history` doc section. Best-effort (empty when absent). Reads the same
/// source as [`mcp_last_run`]; renderers must not open the warehouse themselves.
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<()> {
// B4: short-circuit --help/--version BEFORE touching config, so the tool is
// introspectable even outside a workspace / before NORNIR_SERVER is set.
let args: Vec<String> = std::env::args().collect();
if args.iter().any(|a| a == "--help" || a == "-h") {
println!(
"nornir-mcp {} — MCP (stdio) server exposing nornir's tools to an LLM agent.\n\n\
CLIENT mode (recommended): relay to a remote nornir-server — no local config needed:\n \
NORNIR_SERVER=http://host:7878 NORNIR_SERVER_TOKEN=<token> nornir-mcp\n\n\
EMBEDDED mode: point at a local workspace config:\n \
NORNIR_CONFIG=/path/to/nornir.toml nornir-mcp\n \
(or run inside a dir tree containing a nornir.toml)\n\n\
Wire into Claude Code:\n \
claude mcp add nornir --env NORNIR_SERVER=http://host:7878 \\\n \
--env NORNIR_SERVER_TOKEN=<token> -- nornir-mcp",
env!("CARGO_PKG_VERSION")
);
return Ok(());
}
if args.iter().any(|a| a == "--version" || a == "-V") {
println!("nornir-mcp {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
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();
// B2: in CLIENT mode (NORNIR_SERVER set) the local config is OPTIONAL — every
// real call routes to the remote server over gRPC, so an absent nornir.toml is
// fine: fall back to a default bootstrap and let the server drive. Only require
// a local config for EMBEDDED mode.
let config_path = std::env::var_os("NORNIR_CONFIG").map(PathBuf::from);
let cwd = std::env::current_dir()?;
let loaded = match config_path {
Some(p) => config::load_explicit(&p)?,
None => match config::discover(&cwd) {
Ok(l) => l,
Err(e) => {
if server_target().is_some() {
eprintln!("nornir-mcp: no local nornir.toml — client mode (NORNIR_SERVER set), using default bootstrap");
config::Loaded {
nornir: nornir::config::Nornir::default(),
config_path: cwd.join("nornir.toml"),
workspace_root: cwd.clone(),
}
} else {
return Err(e);
}
}
},
};
eprintln!("starting nornir-mcp; config={}", loaded.config_path.display());
// GOLDEN default: in client mode with no workspace pinned, auto-select one so
// an agent's first knowledge/search call returns DATA instead of the server's
// bare default (the silent empty-`[]` trap). `workspace_use <name>` overrides.
if server_target().is_some() && client_workspace().is_none() {
match pick_default_workspace().await {
Some(ws) => {
set_active_workspace(&ws);
eprintln!(
"nornir-mcp: no NORNIR_WORKSPACE pinned → defaulting active workspace to \
`{ws}` (richest served set; `workspace_use <name>` to switch)"
);
}
None => eprintln!(
"nornir-mcp: client mode, no workspace auto-selected — call \
workspaces_list then workspace_use <name>"
),
}
}
let server = NornirServer::new(loaded).await?.serve(stdio()).await?;
server.waiting().await?;
Ok(())
}
#[cfg(test)]
mod default_ws_tests {
use super::*;
/// LAW (inject-assert): the default-workspace picker chooses the RICHEST
/// served set (most repos), breaking ties alphabetically — so a fresh agent
/// lands on the workspace most likely to hold whatever repo it asks about.
#[test]
fn richest_workspace_picks_most_repos_then_alphabetical() {
// nordisk (8) wins over holger (3)/knut (1).
let ws = vec![
("knut".to_string(), 1),
("holger".to_string(), 3),
("nordisk".to_string(), 8),
];
assert_eq!(richest_workspace(&ws).as_deref(), Some("nordisk"));
// Tie on count → alphabetically-first name.
let tie = vec![
("zeta".to_string(), 5),
("alpha".to_string(), 5),
("mid".to_string(), 2),
];
assert_eq!(richest_workspace(&tie).as_deref(), Some("alpha"));
// Empty → None (caller logs "call workspace_use").
assert_eq!(richest_workspace(&[]), None);
}
}
#[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(),
};
// SAFETY: single-threaded test; restored immediately after.
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(),
};
// Ensure no env override leaks in from another test.
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();
// Lock-down: the unlock (chmod +w) tool must NOT be agent-facing.
assert!(!router.has_route("guard_release"), "guard_release must not be exposed to agents");
// Tamper-evidence + Mímir tools must be present.
for name in [
"guard_apply",
"guard_verify",
"deps_of",
"dependents_of",
"affected_by_change",
"build_order",
"dep_path",
"external_dep_users",
"dep_graph_svg",
"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");
}
// Robot-UI-tester loop: both halves are agent-facing.
assert!(router.has_route("viz.state"), "viz.state (read the live viz UI) must be registered");
assert!(router.has_route("viz.click"), "viz.click (drive the viz) must be registered");
}
}