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::guard;
use nornir::index;
use nornir::introspect;
use nornir::release;
#[derive(Clone)]
struct NornirServer {
state: Arc<Mutex<State>>,
#[allow(dead_code)] tool_router: ToolRouter<NornirServer>,
}
struct State {
loaded: Loaded,
funnel: FunnelStore,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct RepoArg {
repo: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct SearchArgs {
query: String,
#[serde(default)]
corpus: Option<String>,
#[serde(default)]
repo: Option<String>,
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct SymbolLookupArgs {
binary: String,
pattern: String,
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct DefinedInArgs {
binary: String,
file: String,
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct CallQueryArgs {
binary: String,
name: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct PathBetweenArgs {
binary: String,
from: String,
to: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct DocsHistoryArgs {
repo: String,
#[serde(default)]
doc: Option<String>,
#[serde(default)]
version: Option<String>,
#[serde(default)]
format: Option<String>,
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct FunnelSubmitIdeaArgs {
text: String,
#[serde(default)]
source: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct FunnelCreatePlanArgs {
idea_id: String,
summary: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct FunnelAddNodeArgs {
plan_id: String,
kind: String,
#[serde(default)]
title: Option<String>,
#[serde(default)]
prompt: Option<String>,
#[serde(default)]
targets: Vec<String>,
#[serde(default)]
needs: Vec<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct FunnelLinkArgs {
plan_id: String,
from: String,
to: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct FunnelStatusArgs {
plan_id: String,
node_id: String,
status: String,
#[serde(default)]
why: Option<String>,
}
#[tool_router]
impl NornirServer {
async fn new(loaded: Loaded) -> Result<Self> {
let funnel_root = std::env::var_os("NORNIR_FUNNEL_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|| FunnelStore::default_root(&loaded.workspace_root));
let funnel = FunnelStore::open_async(&funnel_root)
.await
.with_context(|| format!("open funnel warehouse at {}", funnel_root.display()))?;
eprintln!(
"funnel: {} ideas, {} plans loaded from {}",
funnel.funnel.ideas.len(),
funnel.funnel.plans.len(),
funnel_root.display(),
);
Ok(Self {
state: Arc::new(Mutex::new(State { loaded, funnel })),
tool_router: Self::tool_router(),
})
}
#[tool(description = "List repos declared in nornir.toml.")]
async fn repos_list(&self) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let names: Vec<String> = s.loaded.nornir.repo.keys().cloned().collect();
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string_pretty(&names).unwrap_or_default(),
)]))
}
#[tool(description = "Guard: report writable state of every [guard].forbidden path.")]
async fn guard_status(&self) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let report = guard::status(&s.loaded.workspace_root, &s.loaded.nornir.guard.forbidden);
Ok(CallToolResult::success(vec![Content::text(format_status(&report))]))
}
#[tool(description = "Guard: chmod -w every [guard].forbidden path that exists.")]
async fn guard_apply(&self) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let report = guard::apply(&s.loaded.workspace_root, &s.loaded.nornir.guard.forbidden)
.map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(format_status(&report))]))
}
#[tool(description = "Guard: chmod +w every [guard].forbidden path (allow human edits).")]
async fn guard_release(&self) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let report = guard::release(&s.loaded.workspace_root, &s.loaded.nornir.guard.forbidden)
.map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(format_status(&report))]))
}
#[tool(description = "Bench: read bench_history.jsonl for <repo> (one BenchRun per line).")]
async fn bench_history(
&self,
Parameters(args): Parameters<RepoArg>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let repo = s.loaded.nornir.repo.get(&args.repo).ok_or_else(|| {
McpError::invalid_params(format!("no [repo.{}]", args.repo), None)
})?;
let history = config::Nornir::repo_dir(&s.loaded.workspace_root, &args.repo)
.join(if repo.history.is_empty() { "bench_history.jsonl" } else { &repo.history });
let runs = bench::history::read_all(&history).map_err(internal)?;
let body = serde_json::to_string_pretty(&runs).map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "Release: run the no-path-patches gate against <repo>'s Cargo.toml.")]
async fn release_gate_path_patches(
&self,
Parameters(args): Parameters<RepoArg>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let repo_root = config::Nornir::repo_dir(&s.loaded.workspace_root, &args.repo);
release::gate::no_path_patches(&repo_root).map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(format!(
"ok: no [patch.crates-io] znippy entries in {}",
repo_root.display()
))]))
}
#[tool(description = "Release: nexus_floor gate — holger_ops_sec ≥ nexus_ops_sec for the latest BenchRun of <repo>.")]
async fn release_gate_nexus_floor(
&self,
Parameters(args): Parameters<RepoArg>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let (root, repo) = repo_ctx(&s, &args.repo).map_err(internal)?;
let run = mcp_last_run(&root, repo).map_err(internal)?;
release::gate::nexus_floor(&run).map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(format!(
"ok: nexus_floor on v{}", run.version
))]))
}
#[tool(description = "Release: no_regression gate — compare latest BenchRun to same-machine history; fails if any metric drops > max_regression_pct.")]
async fn release_gate_no_regression(
&self,
Parameters(args): Parameters<RepoArg>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let (root, repo) = repo_ctx(&s, &args.repo).map_err(internal)?;
let run = mcp_last_run(&root, repo).map_err(internal)?;
let pct = if repo.gates.max_regression_pct > 0.0 { repo.gates.max_regression_pct } else { 10.0 };
let hp = root.join(if repo.history.is_empty() { "bench_history.jsonl" } else { &repo.history });
release::gate::no_regression(&run, &hp, pct).map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(format!(
"ok: no_regression ≤{:.1}% on v{}", pct, run.version
))]))
}
#[tool(description = "Docs: scaffold `.nornir/` for <repo> (migrate any existing README.md/CHANGELOG.md into it).")]
async fn docs_init(
&self,
Parameters(args): Parameters<RepoArg>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let (root, _) = repo_ctx(&s, &args.repo).map_err(internal)?;
let layout = nornir::docs::RepoLayout::new(&root);
let srcs = nornir::docs::init_repo(&layout).map_err(internal)?;
let body = serde_json::json!({
"repo": args.repo,
"nornir_dir": layout.nornir_dir(),
"sources": srcs,
});
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string_pretty(&body).unwrap_or_default(),
)]))
}
#[tool(description = "Docs: render every managed doc for <repo> from .nornir/ (full rewrite, chmod-aware).")]
async fn docs_render(
&self,
Parameters(args): Parameters<RepoArg>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let (root, repo) = repo_ctx(&s, &args.repo).map_err(internal)?;
let layout = nornir::docs::RepoLayout::new(&root);
let last = mcp_last_run(&root, repo).ok();
let ctx = nornir::docs::Ctx {
repo_root: &root,
workspace_root: &s.loaded.workspace_root,
run: last.as_ref(),
};
let reports = nornir::docs::render_all(&layout, &ctx).map_err(internal)?;
let body = serde_json::json!({
"repo": args.repo,
"reports": reports.iter().map(|r| serde_json::json!({
"output": r.output,
"bytes": r.bytes,
"changed": r.changed,
"sections": r.sections,
})).collect::<Vec<_>>(),
});
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string_pretty(&body).unwrap_or_default(),
)]))
}
#[tool(description = "Docs: dry-run check that every artifact (README.md, CHANGELOG.md) matches its .nornir/ source.")]
async fn docs_check(
&self,
Parameters(args): Parameters<RepoArg>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let (root, repo) = repo_ctx(&s, &args.repo).map_err(internal)?;
let layout = nornir::docs::RepoLayout::new(&root);
let last = mcp_last_run(&root, repo).ok();
let ctx = nornir::docs::Ctx {
repo_root: &root,
workspace_root: &s.loaded.workspace_root,
run: last.as_ref(),
};
nornir::docs::render_check_all(&layout, &ctx).map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(format!(
"ok: every doc in {} matches its source", args.repo
))]))
}
#[tool(description = "Docs: list historical exports recorded in .nornir/warehouse/docs/ (newest first). Optional filters: doc, version, format, limit.")]
async fn docs_history(
&self,
Parameters(args): Parameters<DocsHistoryArgs>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let (root, _) = repo_ctx(&s, &args.repo).map_err(internal)?;
let layout = nornir::docs::RepoLayout::new(&root);
let wh = nornir::docs::DocsWarehouse::open(&layout).map_err(internal)?;
let filter = nornir::docs::ExportFilter {
doc_name: args.doc,
version: args.version,
format: args.format,
limit: args.limit.or(Some(50)),
};
let rows = wh.list(&filter).map_err(internal)?;
let body = serde_json::json!({
"repo": args.repo,
"root": wh.root(),
"rows": rows,
});
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string_pretty(&body).unwrap_or_default(),
)]))
}
#[tool(description = "Release: docs_fresh gate — README.md generated sections must be in sync with latest BenchRun.")]
async fn release_gate_docs_fresh(
&self,
Parameters(args): Parameters<RepoArg>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let (root, repo) = repo_ctx(&s, &args.repo).map_err(internal)?;
let layout = nornir::docs::RepoLayout::new(&root);
let run = mcp_last_run(&root, repo).map_err(internal)?;
let ctx = nornir::docs::Ctx {
repo_root: &root,
workspace_root: &s.loaded.workspace_root,
run: Some(&run),
};
nornir::docs::render_check_all(&layout, &ctx).map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(format!(
"ok: docs_fresh on {}", root.display()
))]))
}
#[tool(description = "Release: run every gate enabled in [repo.<name>.gates] for <repo>; returns JSON {passed:[...], failed:[{name,error}]}. Roundtrip invokes `cargo test --test roundtrip_<kind> --release` per configured kind.")]
async fn release_gate_all(
&self,
Parameters(args): Parameters<RepoArg>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let (root, repo) = repo_ctx(&s, &args.repo).map_err(internal)?;
let g = &repo.gates;
let mut passed: Vec<String> = Vec::new();
let mut failed: Vec<serde_json::Value> = Vec::new();
macro_rules! push {
($n:expr, $r:expr) => {
match $r {
Ok(()) => passed.push($n.into()),
Err(e) => failed.push(serde_json::json!({"name": $n, "error": format!("{e:#}")})),
}
};
}
if g.no_path_patches {
push!("no_path_patches", release::gate::no_path_patches(&root));
}
let last = mcp_last_run(&root, repo);
if g.nexus_floor {
push!("nexus_floor", last.as_ref().map_err(|e| anyhow::anyhow!("{e:#}")).and_then(|r| release::gate::nexus_floor(r)));
}
if g.no_regression {
let pct = if g.max_regression_pct > 0.0 { g.max_regression_pct } else { 10.0 };
let hp = root.join(if repo.history.is_empty() { "bench_history.jsonl" } else { &repo.history });
push!("no_regression",
last.as_ref().map_err(|e| anyhow::anyhow!("{e:#}")).and_then(|r| release::gate::no_regression(r, &hp, pct)));
}
if !g.integration_roundtrip.is_empty() {
let kinds: Vec<&str> = g.integration_roundtrip.iter().map(|s| s.as_str()).collect();
push!("integration_roundtrip",
nornir::release::gate::integration_roundtrip_via_cargo_test(&root, &kinds));
}
if g.docs_fresh {
let r: anyhow::Result<()> = (|| {
let run = last.as_ref().map_err(|e| anyhow::anyhow!("{e:#}"))?;
let layout = nornir::docs::RepoLayout::new(&root);
let ctx = nornir::docs::Ctx { repo_root: &root, workspace_root: &s.loaded.workspace_root, run: Some(run) };
nornir::docs::render_check_all(&layout, &ctx)
})();
push!("docs_fresh", r);
}
let body = serde_json::json!({"repo": args.repo, "passed": passed, "failed": failed});
Ok(CallToolResult::success(vec![Content::text(serde_json::to_string_pretty(&body).unwrap())]))
}
#[tool(description = "Full-text BM25 search over indexed corpora. \
Run `nornir index build` first. Args: query (Tantivy syntax), \
optional corpus (docs|code|bench_history|changelog|config), \
optional repo (top-level workspace dir), optional limit.")]
async fn search(
&self,
Parameters(args): Parameters<SearchArgs>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let idx = index::Index::open(&s.loaded.workspace_root).map_err(internal)?;
let corpus = match args.corpus.as_deref() {
None => None,
Some(name) => Some(
index::Corpus::parse(name)
.ok_or_else(|| McpError::invalid_params(format!("unknown corpus: {name}"), None))?,
),
};
let hits = idx
.search(&args.query, corpus, args.repo.as_deref(), args.limit.unwrap_or(10))
.map_err(internal)?;
let body = serde_json::to_string_pretty(&hits).map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "DWARF symbol lookup: extract every function symbol \
from a built binary and filter by name substring. Returns JSON \
array of {name, name_demangled, name_mangled, file, line, size_bytes, krate}. \
`binary` may be relative to workspace root.")]
async fn symbol_lookup(
&self,
Parameters(args): Parameters<SymbolLookupArgs>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let bin = resolve_binary(&s.loaded.workspace_root, &args.binary);
let syms = introspect::artifact::extract_symbols(&bin, &s.loaded.workspace_root)
.map_err(internal)?;
let hits: Vec<_> = introspect::artifact::lookup(&syms, &args.pattern)
.into_iter()
.take(args.limit.unwrap_or(25))
.cloned()
.collect();
let body = serde_json::to_string_pretty(&hits).map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "DWARF defined-in lookup: list every function symbol \
defined in source files whose path ends with `file`. \
`binary` may be relative to workspace root.")]
async fn defined_in(
&self,
Parameters(args): Parameters<DefinedInArgs>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let bin = resolve_binary(&s.loaded.workspace_root, &args.binary);
let syms = introspect::artifact::extract_symbols(&bin, &s.loaded.workspace_root)
.map_err(internal)?;
let hits: Vec<_> = introspect::artifact::defined_in(&syms, &args.file)
.into_iter()
.take(args.limit.unwrap_or(100))
.cloned()
.collect();
let body = serde_json::to_string_pretty(&hits).map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "DWARF inline-callgraph: functions that call `name`. \
Only inlined edges are visible at this layer — indirect calls (trait \
objects, fn pointers) and non-inlined direct calls are NOT included. \
Use demangled names with generics stripped (e.g. `nornir::index::Index::build`).")]
async fn callers_of(
&self,
Parameters(args): Parameters<CallQueryArgs>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let bin = resolve_binary(&s.loaded.workspace_root, &args.binary);
let edges = introspect::callgraph_dwarf::extract_callgraph(&bin, &s.loaded.workspace_root)
.map_err(internal)?;
let cg = introspect::callgraph_dwarf::Callgraph::from_edges(&edges);
let body = serde_json::to_string_pretty(&cg.callers_of(&args.name)).map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "DWARF inline-callgraph: functions called by `name`. \
Inlined edges only (see `callers_of` for caveats).")]
async fn callees_of(
&self,
Parameters(args): Parameters<CallQueryArgs>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let bin = resolve_binary(&s.loaded.workspace_root, &args.binary);
let edges = introspect::callgraph_dwarf::extract_callgraph(&bin, &s.loaded.workspace_root)
.map_err(internal)?;
let cg = introspect::callgraph_dwarf::Callgraph::from_edges(&edges);
let body = serde_json::to_string_pretty(&cg.callees_of(&args.name)).map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "DWARF inline-callgraph: shortest call chain from `from` to `to` \
(BFS over inlined edges). Returns the list of function names along the path, \
or `null` when no path exists.")]
async fn path_between(
&self,
Parameters(args): Parameters<PathBetweenArgs>,
) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let bin = resolve_binary(&s.loaded.workspace_root, &args.binary);
let edges = introspect::callgraph_dwarf::extract_callgraph(&bin, &s.loaded.workspace_root)
.map_err(internal)?;
let cg = introspect::callgraph_dwarf::Callgraph::from_edges(&edges);
let path = cg.path_between(&args.from, &args.to);
let body = serde_json::to_string_pretty(&path).map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "Submit a new idea into the intake funnel. Returns the assigned idea id (e.g. \"i-007\"). Use this when the user or agent surfaces something worth doing but the work hasn't been planned yet.")]
async fn funnel_submit_idea(
&self,
Parameters(args): Parameters<FunnelSubmitIdeaArgs>,
) -> Result<CallToolResult, McpError> {
let mut s = self.state.lock().await;
let id = IdeaId::seq(s.funnel.funnel.next_idea);
let ev = FunnelEvent::IdeaSubmitted {
id: id.clone(),
source: args.source.unwrap_or_else(|| "mcp".into()),
text: args.text,
refs: Vec::new(),
ts: Utc::now(),
};
s.funnel.record_async(ev).await.map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(id.as_str().to_string())]))
}
#[tool(description = "Create a plan that refines an existing idea into executable nodes. Auto-activates the plan. Returns the new plan id (e.g. \"p-003\"). Add nodes with funnel_add_node + funnel_link.")]
async fn funnel_create_plan(
&self,
Parameters(args): Parameters<FunnelCreatePlanArgs>,
) -> Result<CallToolResult, McpError> {
let mut s = self.state.lock().await;
let plan_id = PlanId::seq(s.funnel.funnel.next_plan);
let now = Utc::now();
s.funnel.record_async(FunnelEvent::PlanCreated {
id: plan_id.clone(),
idea_id: IdeaId::new(args.idea_id),
summary: args.summary,
planner: "mcp".into(),
ts: now,
}).await.map_err(internal)?;
s.funnel.record_async(FunnelEvent::PlanStatusChanged {
plan_id: plan_id.clone(),
status: PlanStatus::Active,
why: None,
ts: Utc::now(),
}).await.map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(plan_id.as_str().to_string())]))
}
#[tool(description = "Add a node to a plan. `kind` is a free verb like \"code:write\", \"test:run\", \"doc:update\". Optionally pass `needs` (other node-ids in the same plan) to wire up dependencies in a single call. Returns the new node id (e.g. \"n-042\").")]
async fn funnel_add_node(
&self,
Parameters(args): Parameters<FunnelAddNodeArgs>,
) -> Result<CallToolResult, McpError> {
let mut s = self.state.lock().await;
let plan_id = PlanId::new(args.plan_id);
let node_id = NodeId::seq(s.funnel.funnel.next_node);
let now = Utc::now();
let mut params = serde_json::Map::new();
if let Some(t) = args.title {
params.insert("title".into(), serde_json::Value::String(t));
}
s.funnel.record_async(FunnelEvent::NodeAdded {
plan_id: plan_id.clone(),
node_id: node_id.clone(),
kind: args.kind,
params,
targets: args.targets,
prompt_excerpt: args.prompt,
ts: now,
}).await.map_err(internal)?;
for from in &args.needs {
s.funnel.record_async(FunnelEvent::EdgeAdded {
plan_id: plan_id.clone(),
from_node: NodeId::new(from.clone()),
to_node: node_id.clone(),
ts: Utc::now(),
}).await.map_err(internal)?;
}
s.funnel.funnel.promote_ready();
Ok(CallToolResult::success(vec![Content::text(node_id.as_str().to_string())]))
}
#[tool(description = "Add a dependency edge: node `to` will only become ready once node `from` is done. Both must belong to the same plan.")]
async fn funnel_link(
&self,
Parameters(args): Parameters<FunnelLinkArgs>,
) -> Result<CallToolResult, McpError> {
let mut s = self.state.lock().await;
s.funnel.record_async(FunnelEvent::EdgeAdded {
plan_id: PlanId::new(args.plan_id),
from_node: NodeId::new(args.from),
to_node: NodeId::new(args.to),
ts: Utc::now(),
}).await.map_err(internal)?;
s.funnel.funnel.promote_ready();
Ok(CallToolResult::success(vec![Content::text("ok".to_string())]))
}
#[tool(description = "What should the agent work on next? Returns a JSON array of ready PlanNodes (all deps satisfied) across every active plan, in stable topo order. Empty array = nothing ready (either all done, all blocked, or no active plans). Call this whenever your context resets.")]
async fn funnel_next(&self) -> Result<CallToolResult, McpError> {
let mut s = self.state.lock().await;
s.funnel.funnel.promote_ready();
let next = topo_ready(&mut s.funnel.funnel);
let body = serde_json::to_string_pretty(&next).map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(body)]))
}
#[tool(description = "Flip a node's status. `status` is one of: ready, active, blocked, done, abandoned. Pass `why` when blocking or abandoning. Use `done` after the actual work lands; the funnel will unblock dependents automatically on the next funnel_next call.")]
async fn funnel_status(
&self,
Parameters(args): Parameters<FunnelStatusArgs>,
) -> Result<CallToolResult, McpError> {
let status = match args.status.as_str() {
"ready" => NodeStatus::Ready,
"active" | "in_progress" => NodeStatus::InProgress,
"blocked" => NodeStatus::Blocked,
"done" => NodeStatus::Done,
"failed" => NodeStatus::Failed,
"abandoned" => NodeStatus::Failed, other => {
return Err(McpError::invalid_params(
format!("unknown status {other:?}; expected ready|active|blocked|done|failed"),
None,
));
}
};
let mut s = self.state.lock().await;
s.funnel.record_async(FunnelEvent::NodeStatusChanged {
plan_id: PlanId::new(args.plan_id),
node_id: NodeId::new(args.node_id),
status,
why: args.why,
ts: Utc::now(),
}).await.map_err(internal)?;
s.funnel.funnel.promote_ready();
Ok(CallToolResult::success(vec![Content::text("ok".to_string())]))
}
#[tool(description = "Dump the entire funnel: ideas with their plans, each plan's nodes with status, and the dependency edges. Useful for orienting after a context reset before calling funnel_next.")]
async fn funnel_show(&self) -> Result<CallToolResult, McpError> {
let s = self.state.lock().await;
let f = &s.funnel.funnel;
let mut out = String::new();
use std::fmt::Write;
let _ = writeln!(out, "ideas: {}, plans: {}", f.ideas.len(), f.plans.len());
for (iid, idea) in &f.ideas {
let _ = writeln!(out, " {} [{}] {}", iid.as_str(), idea.source, idea.text);
}
for (pid, plan) in &f.plans {
let _ = writeln!(
out,
" {} (idea {}) [{:?}] {} — {} nodes, {} edges",
pid.as_str(),
plan.idea_id.as_str(),
plan.status,
plan.summary,
plan.nodes.len(),
plan.edges.len(),
);
for (nid, n) in &plan.nodes {
let title = n.params.get("title").and_then(|v| v.as_str()).unwrap_or("");
let _ = writeln!(out, " {} [{:?}] {} {}", nid.as_str(), n.status, n.kind, title);
}
}
Ok(CallToolResult::success(vec![Content::text(out)]))
}
}
#[tool_handler]
impl ServerHandler for NornirServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(
ServerCapabilities::builder().enable_tools().build(),
)
.with_server_info(Implementation::from_build_env())
.with_instructions(
"nornir — companion to cargo. Tools: repos_list, guard_{status,apply,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, \
funnel_{submit_idea,create_plan,add_node,link,next,status,show}. \
The funnel is a persistent DAG of ideas → plans → nodes that survives agent \
context loss; call funnel_show then funnel_next after any restart to find out \
what to work on. The server reads workspace_holger/release/nornir.toml at start; \
restart to pick up edits."
.to_string(),
)
}
}
fn format_status(report: &[guard::PathStatus]) -> String {
let mut s = String::new();
s.push_str(&format!("{:<8} {:<8} {:<8} path\n", "exists", "writable", "changed"));
for p in report {
s.push_str(&format!(
"{:<8} {:<8} {:<8} {}\n",
yn(p.exists), yn(p.writable), yn(p.changed), p.path.display()
));
}
s
}
fn resolve_binary(workspace_root: &std::path::Path, binary: &str) -> std::path::PathBuf {
let p = std::path::PathBuf::from(binary);
if p.is_absolute() { p } else { workspace_root.join(p) }
}
fn yn(b: bool) -> &'static str { if b { "yes" } else { "no" } }
fn internal<E: std::fmt::Display>(e: E) -> McpError {
McpError::internal_error(e.to_string(), None)
}
fn repo_ctx<'a>(
s: &'a tokio::sync::MutexGuard<'a, State>,
repo_name: &str,
) -> anyhow::Result<(PathBuf, &'a config::Repo)> {
let repo = s.loaded.nornir.repo.get(repo_name)
.ok_or_else(|| anyhow::anyhow!("repo `{repo_name}` not in nornir.toml"))?;
let root = config::Nornir::repo_dir(&s.loaded.workspace_root, repo_name);
Ok((root, repo))
}
fn mcp_last_run(repo_root: &std::path::Path, repo: &config::Repo) -> anyhow::Result<bench::BenchRun> {
let path = repo_root.join(if repo.history.is_empty() { "bench_history.jsonl" } else { &repo.history });
let runs = bench::history::read_all(&path)?;
runs.into_iter().last().ok_or_else(|| anyhow::anyhow!("no bench runs in {}", path.display()))
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "nornir_mcp=info".into()),
)
.with_writer(std::io::stderr)
.with_ansi(false)
.init();
let config_path = std::env::var_os("NORNIR_CONFIG").map(PathBuf::from);
let loaded = match config_path {
Some(p) => config::load_explicit(&p)?,
None => config::discover(&std::env::current_dir()?)?,
};
eprintln!("starting nornir-mcp; config={}", loaded.config_path.display());
let server = NornirServer::new(loaded).await?.serve(stdio()).await?;
server.waiting().await?;
Ok(())
}