Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

src/mcp_stdio.rs

//! **The MCP stdio server** — 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.
//!
//! # Why this is a LIBRARY module and not `src/bin/nornir-mcp.rs`
//!
//! It was that bin until 2026-08-03. The owner: *"mcp skall väl ingå inkompilerat i
//! server!"* — and that follows from the build matrix rather than overriding it.
//! `<x>-server` is the one atom declared `role = server, linkage = fat`, "the
//! service, state-holding, **everything compiled in**". MCP is a capability OF
//! that service; a separate `nornir-mcp` atom made it a sixth delivery beside a
//! five-row matrix, and gave the declaration its only `role = "tool"` — a role no
//! other member in the constellation has.
//!
//! So the loop moved here and [`run`] is called from
//! `src/bin/nornir-server.rs`'s `mcp` subcommand:
//!
//! ```text
//! nornir-server mcp     # this module — MCP over stdio (rmcp transport::stdio)
//! nornir-server         # the gRPC daemon on :7878, untouched
//! ```
//!
//! **Two subcommands of one binary, not two binaries**, because MCP is *stdio*:
//! the agent SPAWNS the process and speaks JSON-RPC over its stdin/stdout. A
//! long-running daemon that owns `:7878` cannot serve that — it has no client's
//! stdin to read. Folding MCP into the *gRPC service* would therefore have lost
//! the capability; folding it into the *server binary* keeps it. The server's own
//! `SubmitMcpCalls` RPC is a different thing entirely and always was: it ingests
//! this module's per-call telemetry into the warehouse `mcp_requests` table.
//!
//! Nothing about the wire changed — same rmcp version, same tool names, same
//! stdio framing. Only the executable that carries it.

use std::path::PathBuf;
use std::sync::Arc;

use anyhow::{Context, Result};
use chrono::Utc;
use rmcp::{
    ErrorData as McpError, ServerHandler, ServiceExt,
    handler::server::{router::tool::ToolRouter, wrapper::Parameters},
    model::*,
    tool, tool_handler, tool_router,
    transport::stdio,
};
use tokio::sync::Mutex;

use nornir::autonom::{VIZ_PALETTES, VIZ_TABS};
use nornir::bench;
use nornir::change;
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;
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,
    /// The EMBEDDED door's code room (Mímir v2): the lake under this workspace's
    /// warehouse root, opened on first use. ONE handle per process — a lake is a
    /// single-process skade catalog. With `NORNIR_SERVER` set the tools go over
    /// gRPC and this handle is never opened (the server holds the lake).
    code_room: Arc<nornir::code_room::CodeRoom>,
    /// 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 MimirSqlTemplateArgs {
    /// Template name (omit to list the menu): def-use | change-radius |
    /// call-chain | table-access-lineage.
    #[serde(default)]
    template: Option<String>,
    /// Template parameters (key → value), e.g. {"repo":"knut","symbol":"paint"}.
    #[serde(default)]
    params: Option<std::collections::HashMap<String, String>>,
    /// Return the rendered SQL text without executing it.
    #[serde(default)]
    render_only: bool,
}

// ── Mímir LENS args ─────────────────────────────────────────────────────────
//
// The lens tier folds warehouse tables into ranked findings PLUS an
// `evidence_note` naming any feed that was missing — so a caller can tell
// "nothing is wrong" from "nothing was measured". That distinction is the whole
// point of the tier and it is why an empty result is never silently green.
//
// Two conventions hold across every lens, matching `server_grpc`'s handlers and
// the lens fns themselves, so MCP and gRPC cannot drift:
//   * `repo` omitted/empty  = fold the WHOLE workspace (the lens fns do
//     `if !repo.is_empty() { retain }`), so it is genuinely optional.
//   * `limit` omitted/0     = the standard page of 50.

/// The shared shape of the twelve single-knob lenses (proto `LensQuery`).
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct LensArgs {
    /// Repo to fold, as named in the workspace descriptor. Omit to fold EVERY
    /// member of the workspace.
    #[serde(default)]
    repo: Option<String>,
    /// Max findings to return. Omit for the default page of 50.
    #[serde(default)]
    limit: Option<u32>,
}

/// `mimir_system_boundary`: one repo (or every one), optionally only some
/// classes of system call.
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct SystemBoundaryArgs {
    /// Repo whose own functions to answer for. Omit for every member.
    #[serde(default)]
    repo: Option<String>,
    /// Only these classes, comma-separated: file, network, process, signal,
    /// memory, time, ipc, event, random, system, indirect. Omit for all.
    #[serde(default)]
    class: Option<String>,
    /// Rows (functions with their path) per class, at most 12. Omit for 12.
    #[serde(default)]
    limit: Option<u32>,
}

/// `mimir_similar_code` detects workspace-wide by construction — it ranks
/// near-duplicate code across ALL members; `repo` only narrows which clusters
/// are REPORTED (those touching that repo), never what is detected.
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct SimilarCodeArgs {
    /// Report only clusters touching this repo. Omit to report every cluster
    /// (detection is always workspace-wide).
    #[serde(default)]
    repo: Option<String>,
    /// Max duplicate clusters to return. Omit for the default page of 50.
    #[serde(default)]
    limit: Option<u32>,
}

#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct GenericCostArgs {
    /// Repo to fold. Omit to fold every member of the workspace.
    #[serde(default)]
    repo: Option<String>,
    /// Instantiation floor below which a generic is not considered bloat.
    /// Omit for the default of 2.
    #[serde(default)]
    min_instantiations: Option<u32>,
    /// Max findings to return. Omit for the default page of 50.
    #[serde(default)]
    limit: Option<u32>,
}

#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct ChurnArgs {
    /// Repo to fold. Omit to fold every member of the workspace.
    #[serde(default)]
    repo: Option<String>,
    /// Frequency filter: "high" (default) surfaces hot spots, "low" surfaces
    /// frozen code, "band" keeps only what falls between the two cutoffs.
    #[serde(default)]
    band: Option<String>,
    /// Lower change-frequency cutoff (commits in the last 90 days).
    #[serde(default)]
    cutoff_low: Option<i64>,
    /// Upper change-frequency cutoff (commits in the last 90 days).
    #[serde(default)]
    cutoff_high: Option<i64>,
    /// Max findings to return. Omit for the default page of 50.
    #[serde(default)]
    limit: Option<u32>,
}

#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct AnomalyArgs {
    /// Repo to fold. Omit to fold every member of the workspace.
    #[serde(default)]
    repo: Option<String>,
    /// Z-score threshold for calling a point an outlier. Omit for the default
    /// of 3.0 (three sigma).
    #[serde(default)]
    z_threshold: Option<f64>,
    /// Max findings to return. Omit for the default page of 50.
    #[serde(default)]
    limit: Option<u32>,
}

#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct TrustArgs {
    /// Repo to fold. Omit to fold every member of the workspace.
    #[serde(default)]
    repo: Option<String>,
    /// Inherited-trust floor at or above which the verdict is `act`.
    /// Omit for the default of 0.66.
    #[serde(default)]
    act_at: Option<f64>,
    /// Inherited-trust floor at or above which the verdict is `hold`
    /// (below it, `refuse`). Omit for the default of 0.33.
    #[serde(default)]
    hold_at: Option<f64>,
    /// Max findings to return. Omit for the default page of 50.
    #[serde(default)]
    limit: Option<u32>,
}

#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct UndocApiArgs {
    /// Repo to fold. Omit to fold every member of the workspace.
    #[serde(default)]
    repo: Option<String>,
    /// Churn floor: only report undocumented public API in files with at least
    /// this many commits in the last 90 days. Omit for the default of 2.
    #[serde(default)]
    min_commits_90d: Option<i64>,
    /// Max findings to return. Omit for the default page of 50.
    #[serde(default)]
    limit: Option<u32>,
}

/// The lens page-size default, shared by the local path with `server_grpc`'s
/// handlers (`limit == 0 => 50`) so MCP and gRPC cannot return different pages
/// for the same call.
fn lens_limit(limit: Option<u32>) -> usize {
    match limit.unwrap_or(0) {
        0 => 50,
        n => n as usize,
    }
}

#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
#[allow(dead_code)] // fields read only in embed-feature builds
struct KnowledgeContextArgs {
    /// Natural-language or code query — embedded with the repo's model.
    query: String,
    /// Repo to slice (embeddings and the code room's nodes are per-repo).
    repo: String,
    /// Pin a historical git SHA prefix for time-travel; omit for the latest.
    #[serde(default)]
    sha: String,
    /// Max ranked slices (default 8).
    #[serde(default)]
    limit: Option<usize>,
    /// Change-radius seed symbol(s): keep only slices within `hops` call-graph
    /// hops of a seed. Empty = no radius trim.
    #[serde(default)]
    seeds: Vec<String>,
    /// Call-graph hop radius from the seeds (default 1; only used with `seeds`).
    #[serde(default)]
    hops: Option<usize>,
    /// Also follow reverse edges (callers) when expanding the radius.
    #[serde(default)]
    include_callers: bool,
}

#[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 WarehouseSqlArgs {
    /// SQL to run over the warehouse tables, e.g.
    /// `SELECT repo, COUNT(*) AS n FROM git_heat_facts GROUP BY repo`.
    /// Table names are the warehouse table names; `SELECT … WHERE / JOIN /
    /// GROUP BY / ORDER BY / LIMIT` all work.
    sql: String,
}

#[derive(Debug, Default, serde::Deserialize, schemars::JsonSchema)]
struct WarehouseTablesArgs {
    /// Optional table to DESCRIBE: when set, returns that table's columns
    /// (`name` · `data_type` · `nullable`) so a consumer knows what a `SELECT`
    /// may reference. When omitted, returns the list of every queryable table
    /// name (the valid `FROM` targets for `warehouse_query`).
    #[serde(default)]
    table: Option<String>,
}

#[derive(Debug, Default, serde::Deserialize, schemars::JsonSchema)]
struct DoctorArgs {
    /// Optional forbidden versions, e.g. `["arrow=56"]` — bans that MAJOR across the constellation.
    #[serde(default)]
    forbid: Vec<String>,
    /// Return the full structured JSON report instead of the human-readable summary.
    #[serde(default)]
    json: bool,
    /// Also derive the SEMANTIC BLAST of every dirty repo (what its uncommitted
    /// change breaks in the repos that depend on it): the nightly driver dumps the
    /// dirty tree — as long as a `cargo check` of it — and the code room answers who
    /// names what changed. Fails closed (no report) when that cannot be derived.
    #[serde(default)]
    semantic_blast: bool,
}

#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct TestHistoryArgs {
    /// Repo name (or, if no run matches a repo, a run id) — the SAME selector
    /// `nornir test history <repo>` accepts.
    repo: String,
    /// Cap to the N most recent runs (0/omitted = all).
    #[serde(default)]
    limit: Option<usize>,
}

#[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
    /// [`nornir::autonom::VIZ_PALETTES`] (`-`/`_`/spaces interchange); an unknown
    /// value is rejected with the full list. Not enumerated here — a doc comment
    /// cannot track the const, so a copy would drift. 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 AirgapInstallArgs {
    /// The received airgap bundle to install (`*.tar.zst`, or `*.znippy` when this
    /// build carries the `airgap-znippy` engine). Its sidecar manifest is verified
    /// on receipt before a byte is trusted.
    bundle: String,
    /// Install prefix — `software/<version>/` unfolds under it and `current` flips to
    /// the new generation. Default: `~/.local/<pkg>` (`./<pkg>` if `$HOME` is unset).
    #[serde(default)]
    prefix: Option<String>,
    /// The non-service prefix payload to install (a `skidbladnir::product::prefix_registry`
    /// name; default `rust-dev-rhel8`).
    #[serde(default)]
    pkg: Option<String>,
    /// Generation stamp for this install (default: today, YYYYMMDD).
    #[serde(default)]
    version: 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)
        };

        let code_room = Arc::new(nornir::code_room::CodeRoom::new(
            &loaded
                .workspace_root
                .file_name()
                .map(|n| n.to_string_lossy().into_owned())
                .unwrap_or_default(),
            &loaded.warehouse_root(),
        ));
        Ok(Self {
            state: Arc::new(Mutex::new(State {
                loaded,
                funnel,
                code_room,
                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 crate::http_agent().get(&url).call() {
                Ok(resp) => Ok(Some(resp.into_body().read_to_string()?)),
                Err(ureq::Error::StatusCode(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` (nornir-ui) 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 a viz tab debug name — NOT enumerated here, because an attribute \
        literal cannot track `autonom::VIZ_TABS` and a hand-kept copy drifts; pass an unknown value \
        and the error names every accepted tab. \
        `palette` re-skins every pane and is likewise NOT enumerated here; pass an \
        unknown value and the error names every accepted palette. 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 come from `autonom::VIZ_PALETTES` — the
        // ONE canonical list. It lives in `autonom` rather than beside the themes
        // because `facett-core` pulls egui unconditionally and this binary must
        // build headless; the `viz_palettes_const_matches_theme_names` test in the
        // viz crate holds it against `Theme::names()`. A literal copy lived here
        // and claimed to be "kept in lock-step" with nothing checking — the same
        // sentence VIZ_TABS carried while it sat nine tabs behind.
        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,
                            purge: false,
                        })
                        .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(),
                        purge: false,
                    })
                    .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,
                // MCP contract: `descriptor` is a server-readable path; pass through.
                descriptor_content: String::new(),
            })
            .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(),
                purge: false,
            })
            .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 { resolved, .. }) if resolved.exists() => {
                                Some((n.clone(), resolved))
                            }
                            _ => 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, room) = {
            let s = self.state.lock().await;
            (s.loaded.warehouse_root(), s.code_room.clone())
        };
        let repo = args.repo.clone();
        let value = tokio::task::spawn_blocking(move || -> anyhow::Result<serde_json::Value> {
            let wh = IcebergWarehouse::open(&warehouse_root)?;
            // the knowledge block is the code room's
            nornir::mimir::repo_overview_in(&mimir.graph, &nornir::code_room::Reach::new(&room, &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![ContentBlock::text(r.json)]));
        }
        let mimir = self.mimir().await?;
        Ok(CallToolResult::success(vec![ContentBlock::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)
    }

    // ── Mímir LENSES ────────────────────────────────────────────────────────
    //
    // The analysis tier: each lens folds two-to-five warehouse tables into
    // ranked findings plus an `evidence_note` naming any feed that was missing.
    // Sixteen were served on the wire and reachable from MCP by NONE of them —
    // so the surface this constellation's own LAW points agents at first could
    // not reach the analysis tier at all, and agents hand-rolled greps for
    // questions `similar_code` and `name_collision` already answer. These
    // sixteen tools close that.
    //
    // Every one runs against the SERVER when `NORNIR_SERVER` is set, and falls
    // back to the local warehouse otherwise — the same dual path as the
    // dependency-graph tools, so a thin client and a fat one behave alike.

    #[tool(
        description = "Mímir lens — GENERIC BLOAT: the code room's binary stream (symbol sizes of a built artifact, joined to the source nodes). \
        Ranks generics by how much compiled SIZE their instantiations actually cost, so you can tell an \
        expensive monomorphisation from a cheap one instead of guessing. `min_instantiations` (default 2) \
        is the floor below which a generic is not bloat."
    )]
    async fn mimir_generic_cost(
        &self,
        Parameters(args): Parameters<GenericCostArgs>,
    ) -> Result<CallToolResult, McpError> {
        let repo = args.repo.clone().unwrap_or_default();
        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
                .generic_cost(pb::GenericCostQuery {
                    repo,
                    min_instantiations: args.min_instantiations.unwrap_or(0),
                    limit: args.limit.unwrap_or(0),
                })
                .await
                .map_err(internal)?
                .into_inner();
            return mimir_emit(&r.json);
        }
        self.lens_embedded(
            "generic_cost",
            serde_json::json!({ "repo": repo, "min_instantiations": args.min_instantiations.unwrap_or(0), "limit": args.limit.unwrap_or(0) }),
        )
        .await
    }

    #[tool(
        description = "Mímir lens — ZOMBIE FEATURES: the code room's feature nodes × ENABLES / GATED_BY edges (× symbol sizes when a binary is there). \
        Cargo features that are declared and compiled but whose gated code nothing reaches — dead weight \
        that still costs build time and binary size."
    )]
    async fn mimir_zombie_features(
        &self,
        Parameters(args): Parameters<LensArgs>,
    ) -> Result<CallToolResult, McpError> {
        let repo = args.repo.clone().unwrap_or_default();
        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
                .zombie_features(pb::LensQuery {
                    repo,
                    limit: args.limit.unwrap_or(0),
                })
                .await
                .map_err(internal)?
                .into_inner();
            return mimir_emit(&r.json);
        }
        self.lens_embedded(
            "zombie_features",
            serde_json::json!({ "repo": repo, "limit": args.limit.unwrap_or(0) }),
        )
        .await
    }

    #[tool(
        description = "Mímir lens — CHANGE RISK: git_heat_facts × the code room's CALLS fan-in × coverage × semver_diffs. \
        Ranks what is risky to touch by combining how much it changes, how much depends on it, how well \
        it is covered, and whether it has broken semver before. The 'what should I be careful with?' lens."
    )]
    async fn mimir_change_risk(
        &self,
        Parameters(args): Parameters<LensArgs>,
    ) -> Result<CallToolResult, McpError> {
        let repo = args.repo.clone().unwrap_or_default();
        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
                .change_risk(pb::LensQuery {
                    repo,
                    limit: args.limit.unwrap_or(0),
                })
                .await
                .map_err(internal)?
                .into_inner();
            return mimir_emit(&r.json);
        }
        self.lens_embedded(
            "change_risk",
            serde_json::json!({ "repo": repo, "limit": args.limit.unwrap_or(0) }),
        )
        .await
    }

    #[tool(
        description = "Mímir lens — SIMILAR CODE: one vector per function node × the code room's signatures and callees. \
        Ranks near-duplicate code ACROSS THE WHOLE WORKSPACE using the semantic embeddings, so it finds \
        re-implementations that share no identifiers and that grep cannot see. Ask this BEFORE writing a \
        new helper, and before concluding two repos do the same thing. Takes no `repo` — it is cross-repo \
        by construction."
    )]
    async fn mimir_similar_code(
        &self,
        Parameters(args): Parameters<SimilarCodeArgs>,
    ) -> Result<CallToolResult, McpError> {
        let repo = args.repo.clone().unwrap_or_default();
        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
                .similar_code(pb::LensQuery {
                    repo,
                    limit: args.limit.unwrap_or(0),
                })
                .await
                .map_err(internal)?
                .into_inner();
            return mimir_emit(&r.json);
        }
        self.lens_embedded(
            "similar_code",
            serde_json::json!({ "repo": repo, "limit": args.limit.unwrap_or(0) }),
        )
        .await
    }

    #[tool(
        description = "Mímir lens — SEMANTIC CLONES: clones by what a function DOES. The model over the NORMALISED body (every name \
        the workspace chose replaced by its role) with cosine ≥ 0.90 AND agreement of the normalised-body signal (exact / near copy) or the \
        structural one (same rare types, fields, callees). Names never decide a pair; every finding says which signals found it. \
        The raw, name-driven view is mimir_same_intent. `repo` narrows REPORTING; detection is always workspace-wide."
    )]
    async fn mimir_semantic_clones(
        &self,
        Parameters(args): Parameters<LensArgs>,
    ) -> Result<CallToolResult, McpError> {
        let repo = args.repo.clone().unwrap_or_default();
        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
                .semantic_clones(pb::LensQuery {
                    repo,
                    limit: args.limit.unwrap_or(0),
                })
                .await
                .map_err(internal)?
                .into_inner();
            return mimir_emit(&r.json);
        }
        self.lens_embedded(
            "semantic_clones",
            serde_json::json!({ "repo": repo, "limit": args.limit.unwrap_or(0) }),
        )
        .await
    }

    #[tool(
        description = "Mímir lens — EXACT CLONES: functions whose NORMALISED bodies are identical — every name the workspace chose replaced by its role (a consistent rename matches), literals by kind, attributes, comments and layout gone. No ML, names play no part. Groups, each member with file:line. `repo` narrows REPORTING; detection is workspace-wide."
    )]
    async fn mimir_exact_clones(
        &self,
        Parameters(args): Parameters<LensArgs>,
    ) -> Result<CallToolResult, McpError> {
        let repo = args.repo.clone().unwrap_or_default();
        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
                .exact_clones(pb::LensQuery {
                    repo,
                    limit: args.limit.unwrap_or(0),
                })
                .await
                .map_err(internal)?
                .into_inner();
            return mimir_emit(&r.json);
        }
        self.lens_embedded(
            "exact_clones",
            serde_json::json!({ "repo": repo, "limit": args.limit.unwrap_or(0) }),
        )
        .await
    }

    #[tool(
        description = "Mímir lens — NEAR CLONES: functions whose normalised bodies are near copies (MinHash/LSH over role shingles, Jaccard estimate ≥ 0.80) — a statement added, changed or dropped, under any names. `repo` narrows REPORTING; detection is workspace-wide."
    )]
    async fn mimir_near_clones(
        &self,
        Parameters(args): Parameters<LensArgs>,
    ) -> Result<CallToolResult, McpError> {
        let repo = args.repo.clone().unwrap_or_default();
        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
                .near_clones(pb::LensQuery {
                    repo,
                    limit: args.limit.unwrap_or(0),
                })
                .await
                .map_err(internal)?
                .into_inner();
            return mimir_emit(&r.json);
        }
        self.lens_embedded(
            "near_clones",
            serde_json::json!({ "repo": repo, "limit": args.limit.unwrap_or(0) }),
        )
        .await
    }

    #[tool(
        description = "Mímir lens — STRUCTURAL CLONES: functions that touch the same things in the code graph — param/return types, types used, fields read, callees — each weighted by its RARITY (sharing a rare struct counts, sharing Result<()> does not). Names play no part; each pair says what it shares (both return `MapLayout`, both read `.MapLayout.repos`). `repo` narrows REPORTING; detection is workspace-wide."
    )]
    async fn mimir_structural_clones(
        &self,
        Parameters(args): Parameters<LensArgs>,
    ) -> Result<CallToolResult, McpError> {
        let repo = args.repo.clone().unwrap_or_default();
        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
                .structural_clones(pb::LensQuery {
                    repo,
                    limit: args.limit.unwrap_or(0),
                })
                .await
                .map_err(internal)?
                .into_inner();
            return mimir_emit(&r.json);
        }
        self.lens_embedded(
            "structural_clones",
            serde_json::json!({ "repo": repo, "limit": args.limit.unwrap_or(0) }),
        )
        .await
    }

    #[tool(
        description = "Mímir lens — ROLE NEIGHBOURS: nearest by role — FastRP graph vectors (IDF-weighted types, fields, callees, callers; two propagation steps over the code room on GraphBLAS). Functions with unrelated names in the same role are neighbours; each hit says which features it shares. `repo` narrows REPORTING; detection is workspace-wide."
    )]
    async fn mimir_role_neighbours(
        &self,
        Parameters(args): Parameters<LensArgs>,
    ) -> Result<CallToolResult, McpError> {
        let repo = args.repo.clone().unwrap_or_default();
        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
                .role_neighbours(pb::LensQuery {
                    repo,
                    limit: args.limit.unwrap_or(0),
                })
                .await
                .map_err(internal)?
                .into_inner();
            return mimir_emit(&r.json);
        }
        self.lens_embedded(
            "role_neighbours",
            serde_json::json!({ "repo": repo, "limit": args.limit.unwrap_or(0) }),
        )
        .await
    }

    #[tool(
        description = "Mímir lens — SEMANTIC TWINS: v1's semantic twins on v2's signals. TWINS = independent re-implementations: the same ROLE in the code graph (FastRP: the same rare types in and out, callees, callers) AND the same MEANING (the model over the name-free normalised body) AND written differently. COPIES = the same or a near-same normalised body that also agrees in structure. Trait-impl families, constructors, one-line accessors and test fixtures are left out and counted. Each finding explains itself. `repo` narrows REPORTING; detection is workspace-wide."
    )]
    async fn mimir_semantic_twins(
        &self,
        Parameters(args): Parameters<LensArgs>,
    ) -> Result<CallToolResult, McpError> {
        let repo = args.repo.clone().unwrap_or_default();
        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
                .semantic_twins(pb::LensQuery {
                    repo,
                    limit: args.limit.unwrap_or(0),
                })
                .await
                .map_err(internal)?
                .into_inner();
            return mimir_emit(&r.json);
        }
        self.lens_embedded(
            "semantic_twins",
            serde_json::json!({ "repo": repo, "limit": args.limit.unwrap_or(0) }),
        )
        .await
    }

    #[tool(
        description = "Mímir lens — REIMPLEMENTS SHARED: a crate that DEPENDS ON crate B and holds a copy or twin of one of B's functions — call the library's instead. The copy/twin pairs (semantic_twins, similar_code) joined with the DEPENDS_ON edges: B is the library because A imports it (Cargo has no cycles). Each finding names the function to replace, the one to call, drop_in or adapter (parameter and return types), whether B is public, the evidence and the gain (lines, callers). Test code, wrappers that already call B and peers with no dependency are left out and counted. `repo` narrows to functions of that repo to replace."
    )]
    async fn mimir_reimplements_shared(
        &self,
        Parameters(args): Parameters<LensArgs>,
    ) -> Result<CallToolResult, McpError> {
        let repo = args.repo.clone().unwrap_or_default();
        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
                .reimplements_shared(pb::LensQuery {
                    repo,
                    limit: args.limit.unwrap_or(0),
                })
                .await
                .map_err(internal)?
                .into_inner();
            return mimir_emit(&r.json);
        }
        self.lens_embedded(
            "reimplements_shared",
            serde_json::json!({ "repo": repo, "limit": args.limit.unwrap_or(0) }),
        )
        .await
    }

    #[tool(
        description = "Mímir lens — SAME INTENT — NAME-DRIVEN: the raw-text model, names and comments included ON PURPOSE: functions that READ alike (all the delete-like functions), whatever their bodies. Not a clone finding — the answer says so. `repo` narrows REPORTING; detection is workspace-wide."
    )]
    async fn mimir_same_intent(
        &self,
        Parameters(args): Parameters<LensArgs>,
    ) -> Result<CallToolResult, McpError> {
        let repo = args.repo.clone().unwrap_or_default();
        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
                .same_intent(pb::LensQuery {
                    repo,
                    limit: args.limit.unwrap_or(0),
                })
                .await
                .map_err(internal)?
                .into_inner();
            return mimir_emit(&r.json);
        }
        self.lens_embedded(
            "same_intent",
            serde_json::json!({ "repo": repo, "limit": args.limit.unwrap_or(0) }),
        )
        .await
    }

    #[tool(
        description = "Mímir lens — WEAK TESTS: test_outcomes × git_heat_facts × coverage_fn. \
        Tests that exist but earn little: never-failing tests over churny code, and covered-on-paper \
        functions whose tests cannot actually fail. Finds the false-green half of a test suite."
    )]
    async fn mimir_weak_tests(
        &self,
        Parameters(args): Parameters<LensArgs>,
    ) -> Result<CallToolResult, McpError> {
        let repo = args.repo.clone().unwrap_or_default();
        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
                .weak_tests(pb::LensQuery {
                    repo,
                    limit: args.limit.unwrap_or(0),
                })
                .await
                .map_err(internal)?
                .into_inner();
            return mimir_emit(&r.json);
        }
        self.lens_embedded(
            "weak_tests",
            serde_json::json!({ "repo": repo, "limit": args.limit.unwrap_or(0) }),
        )
        .await
    }

    #[tool(
        description = "Mímir lens — BENCH GAPS: git_heat_facts × the code room's CALLS × coverage_fn × bench_results. \
        Hot, widely-depended-on code paths that no benchmark measures — where a performance regression \
        would land unseen."
    )]
    async fn mimir_bench_gaps(
        &self,
        Parameters(args): Parameters<LensArgs>,
    ) -> Result<CallToolResult, McpError> {
        let repo = args.repo.clone().unwrap_or_default();
        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
                .bench_gaps(pb::LensQuery {
                    repo,
                    limit: args.limit.unwrap_or(0),
                })
                .await
                .map_err(internal)?
                .into_inner();
            return mimir_emit(&r.json);
        }
        self.lens_embedded(
            "bench_gaps",
            serde_json::json!({ "repo": repo, "limit": args.limit.unwrap_or(0) }),
        )
        .await
    }

    // NOT `#[cfg]`-stripped as a whole. `#[tool_router]` on the impl block expands
    // BEFORE this item's own `cfg` is evaluated, so it registers `Self::mimir_subclone`
    // in the router and the cfg then deletes the function out from under it — E0599 at
    // the definition site, in every build without `vector`. That is why
    // `--no-default-features --features server,mcp` did not compile: a combination
    // nothing built, because the `nornir-server` bin also required `viz` and so the
    // headless daemon was not a buildable artifact at all. The tool therefore always
    // EXISTS and its BODY carries the gate — a named refusal, never a missing tool that
    // an agent reads as "this lens found nothing".
    #[tool(
        description = "Mímir lens — SUB-ITEM CLONES in ONE repo's source. Finds duplicated \
        regions SMALLER than the function they sit in — the class no chunk-keyed tier can state \
        at any threshold. `repo` is REQUIRED: asked without one the lens REFUSES and names the \
        repos, because 65 findings on one repo is a list you work through and 347 across three \
        is a list you close. Folds SOURCE TEXT (the materialised checkout), not warehouse \
        tables, so a repo with no checkout answers NO SOURCE TREE rather than 'no clones'. \
        Type-2 normalisation is ON by default; NORNIR_SUBCLONE_NORMALIZE=0 turns it off (and \
        loses the verified top find — see the module docs)."
    )]
    async fn mimir_subclone(
        &self,
        Parameters(args): Parameters<LensArgs>,
    ) -> Result<CallToolResult, McpError> {
        // No `server_target()` branch, and that is a decision rather than an
        // omission: there is no `Mimir.Subclone` gRPC verb, so a remote hop
        // would call a method that does not exist. This lens folds the local
        // checkout, which is the same tree a server on this box would read.
        #[cfg(feature = "vector")]
        {
            let repo = args.repo.clone().unwrap_or_default();
            let limit = lens_limit(args.limit);
            self.lens_embedded("subclone", serde_json::json!({ "repo": repo, "limit": limit })).await
        }
        #[cfg(not(feature = "vector"))]
        {
            let _ = args;
            Err(McpError::invalid_params(
                "mimir_subclone needs the `vector` feature: the sub-item clone detector \
                 rides the embedding stack it scores with (c5bd222). This build does not \
                 carry it — this is a REFUSAL, not an empty finding list.",
                None,
            ))
        }
    }

    #[tool(
        description = "Mímir lens — VILLA (shell-out heresy): the code room's CALLS reachability to impure nodes. \
        Finds IMPURE code in a pure-Rust constellation: std::process::Command, exec/system, and reliance \
        on external tools. Enforces the 'never shell out' law by showing where it is already broken."
    )]
    async fn mimir_villa(
        &self,
        Parameters(args): Parameters<LensArgs>,
    ) -> Result<CallToolResult, McpError> {
        let repo = args.repo.clone().unwrap_or_default();
        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
                .villa(pb::LensQuery {
                    repo,
                    limit: args.limit.unwrap_or(0),
                })
                .await
                .map_err(internal)?
                .into_inner();
            return mimir_emit(&r.json);
        }
        self.lens_embedded(
            "villa",
            serde_json::json!({ "repo": repo, "limit": args.limit.unwrap_or(0) }),
        )
        .await
    }

    #[tool(
        description = "Mímir lens — CHURN frequency filter: git_heat_facts × the code room's items. \
        A band-pass over change frequency: `band`=\"high\" (default) ranks churn×complexity hotspots, \
        \"low\" surfaces frozen code, \"band\" keeps what falls between `cutoff_low` and `cutoff_high` \
        (commits in the last 90 days)."
    )]
    async fn mimir_churn(
        &self,
        Parameters(args): Parameters<ChurnArgs>,
    ) -> Result<CallToolResult, McpError> {
        let repo = args.repo.clone().unwrap_or_default();
        let band = args.band.clone().unwrap_or_default();
        let (lo, hi) = (args.cutoff_low.unwrap_or(0), args.cutoff_high.unwrap_or(0));
        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
                .churn(pb::ChurnQuery {
                    repo,
                    band,
                    cutoff_low: lo,
                    cutoff_high: hi,
                    limit: args.limit.unwrap_or(0),
                })
                .await
                .map_err(internal)?
                .into_inner();
            return mimir_emit(&r.json);
        }
        self.lens_embedded(
            "churn",
            serde_json::json!({ "repo": repo, "band": args.band.clone().unwrap_or_default(), "cutoff_low": args.cutoff_low.unwrap_or(0), "cutoff_high": args.cutoff_high.unwrap_or(0), "limit": args.limit.unwrap_or(0) }),
        )
        .await
    }

    #[tool(
        description = "Mímir lens — ANOMALY: git_heat_facts across ALL snapshots. \
        Z-score/EWMA outliers in the change time-series: a normally-stable file suddenly churning, \
        activity spikes, unusual author breadth, new-coupling proxies. `z_threshold` defaults to 3.0."
    )]
    async fn mimir_anomaly(
        &self,
        Parameters(args): Parameters<AnomalyArgs>,
    ) -> Result<CallToolResult, McpError> {
        let repo = args.repo.clone().unwrap_or_default();
        let z = args.z_threshold.unwrap_or(0.0);
        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
                .anomaly(pb::AnomalyQuery {
                    repo,
                    z_threshold: z,
                    limit: args.limit.unwrap_or(0),
                })
                .await
                .map_err(internal)?
                .into_inner();
            return mimir_emit(&r.json);
        }
        self.lens_embedded(
            "anomaly",
            serde_json::json!({ "repo": repo, "z_threshold": args.z_threshold.unwrap_or(0.0), "limit": args.limit.unwrap_or(0) }),
        )
        .await
    }

    #[tool(
        description = "Mímir lens — WORLD IMPACT: semver_diffs × the code room's CALLS × dep_graph_edges. \
        Ranks changes by how far they reach — downstream inside the constellation AND out into the \
        published world. Answers 'who does this actually break?' rather than 'what did I edit?'."
    )]
    async fn mimir_world_impact(
        &self,
        Parameters(args): Parameters<LensArgs>,
    ) -> Result<CallToolResult, McpError> {
        let repo = args.repo.clone().unwrap_or_default();
        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
                .world_impact(pb::LensQuery {
                    repo,
                    limit: args.limit.unwrap_or(0),
                })
                .await
                .map_err(internal)?
                .into_inner();
            return mimir_emit(&r.json);
        }
        self.lens_embedded(
            "world_impact",
            serde_json::json!({ "repo": repo, "limit": args.limit.unwrap_or(0) }),
        )
        .await
    }

    #[tool(
        description = "Mímir lens — BINARY DRIFT: the code room's binary stream MINUS \
        its source nodes and CALLS, plus dwarf_* edges. A compile-time-injection detector: symbols \
        present in the SHIPPED BINARY that the source graph cannot explain. What is in the artifact \
        that is not in the code."
    )]
    async fn mimir_binary_drift(
        &self,
        Parameters(args): Parameters<LensArgs>,
    ) -> Result<CallToolResult, McpError> {
        let repo = args.repo.clone().unwrap_or_default();
        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
                .binary_drift(pb::LensQuery {
                    repo,
                    limit: args.limit.unwrap_or(0),
                })
                .await
                .map_err(internal)?
                .into_inner();
            return mimir_emit(&r.json);
        }
        self.lens_embedded(
            "binary_drift",
            serde_json::json!({ "repo": repo, "limit": args.limit.unwrap_or(0) }),
        )
        .await
    }

    #[tool(
        description = "Mímir lens — DATA REACH: TOUCHES_TABLE edges (written by the rustc driver) × REVERSED CALLS × \
        node visibility. Sensitive-data reachability: which public entry points can reach code \
        that touches which tables — i.e. the leak paths."
    )]
    async fn mimir_data_reach(
        &self,
        Parameters(args): Parameters<LensArgs>,
    ) -> Result<CallToolResult, McpError> {
        let repo = args.repo.clone().unwrap_or_default();
        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
                .data_reach(pb::LensQuery {
                    repo,
                    limit: args.limit.unwrap_or(0),
                })
                .await
                .map_err(internal)?
                .into_inner();
            return mimir_emit(&r.json);
        }
        self.lens_embedded(
            "data_reach",
            serde_json::json!({ "repo": repo, "limit": args.limit.unwrap_or(0) }),
        )
        .await
    }

    #[tool(
        description = "Mímir lens — SYSTEM BOUNDARY: which classes of system call (file, network, process, \
        signal, memory, time, ipc, event, random, system, indirect) a repo's own functions can reach, \
        and from where — the functions where our code crosses into std / a dependency toward the call \
        first, each with ONE shortest path (our fn → std → libc `ffi` → the syscall, and the kernel \
        function it enters when the kernel's BTF was read). The code room with the dependencies folded \
        in: CALLS × SYSCALLS (curated glibc / musl map; uncertain hops marked). `class` narrows it \
        (`network`)."
    )]
    async fn mimir_system_boundary(
        &self,
        Parameters(args): Parameters<SystemBoundaryArgs>,
    ) -> Result<CallToolResult, McpError> {
        let params = serde_json::json!({
            "repo": args.repo.clone().unwrap_or_default(),
            "class": args.class.clone().unwrap_or_default(),
            "limit": args.limit.unwrap_or(0),
        });
        if server_target().is_some() {
            // no `Mimir.*` verb of its own: the code room's one door
            let (channel, bearer) = mcp_connect().await?;
            let mut c = pb::code_room_client::CodeRoomClient::with_interceptor(channel, mcp_auth(bearer));
            let r = c
                .answer(pb::CodeRoomQuestion { surface: "Mimir.SystemBoundary".into(), params_json: params.to_string() })
                .await
                .map_err(internal)?
                .into_inner();
            return mimir_emit(&r.json);
        }
        self.lens_embedded("system_boundary", params).await
    }

    #[tool(
        description = "Mímir lens — TRUST (provenance semiring): the code room's CALLS walked FORWARD from the \
        entry surface under (⊕=max over paths, ⊗=min weakest-hop). Local trust is docked by shell-out \
        impurity × sensitive-data touch × test coverage, then inherited trust maps to act/hold/refuse. \
        `act_at` defaults 0.66, `hold_at` 0.33."
    )]
    async fn mimir_trust(
        &self,
        Parameters(args): Parameters<TrustArgs>,
    ) -> Result<CallToolResult, McpError> {
        let repo = args.repo.clone().unwrap_or_default();
        let (act, hold) = (args.act_at.unwrap_or(0.0), args.hold_at.unwrap_or(0.0));
        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
                .trust(pb::TrustQuery {
                    repo,
                    act_at: act,
                    hold_at: hold,
                    limit: args.limit.unwrap_or(0),
                })
                .await
                .map_err(internal)?
                .into_inner();
            return mimir_emit(&r.json);
        }
        self.lens_embedded(
            "trust",
            serde_json::json!({ "repo": repo, "act_at": args.act_at.unwrap_or(0.0), "hold_at": args.hold_at.unwrap_or(0.0), "limit": args.limit.unwrap_or(0) }),
        )
        .await
    }

    #[tool(
        description = "Mímir lens — NAME COLLISION (graph self-audit): the code room's nodes × CALLS. \
        Leaf function names defined in more than one place that the string-name matcher conflates, plus \
        the FRACTION of call edges pointing at an ambiguous callee. Run this before trusting any \
        callers_of/callees_of answer — it tells you how sound the graph you are querying actually is."
    )]
    async fn mimir_name_collision(
        &self,
        Parameters(args): Parameters<LensArgs>,
    ) -> Result<CallToolResult, McpError> {
        let repo = args.repo.clone().unwrap_or_default();
        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
                .name_collision(pb::LensQuery {
                    repo,
                    limit: args.limit.unwrap_or(0),
                })
                .await
                .map_err(internal)?
                .into_inner();
            return mimir_emit(&r.json);
        }
        self.lens_embedded(
            "name_collision",
            serde_json::json!({ "repo": repo, "limit": args.limit.unwrap_or(0) }),
        )
        .await
    }

    #[tool(
        description = "Mímir lens — SYMBOL IDENTITY (substrate invariant gate): the code room's node ids (SCIP monikers). \
        Answers whether the RESOLVED symbol column is actually an identity — one definition per symbol \
        per snapshot — which is the precondition for EVERY duplicate claim above it. Run this BEFORE \
        trusting mimir_similar_code: a detector reasoning over an ambiguous symbol table cannot be right \
        about duplicates however good its threshold. Splits the two causes by OWNER: `unscoped-local` is \
        OURS (rows written by a binary predating the document-scoping fix — the report NAMES the repos to \
        re-ingest), `upstream duplicate moniker` is rust-analyzer defining one global moniker twice, which \
        no re-ingest cures. An unfed table reads `unknown`, never `sound`."
    )]
    async fn mimir_symbol_identity(
        &self,
        Parameters(args): Parameters<LensArgs>,
    ) -> Result<CallToolResult, McpError> {
        let repo = args.repo.clone().unwrap_or_default();
        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
                .symbol_identity(pb::LensQuery {
                    repo,
                    limit: args.limit.unwrap_or(0),
                })
                .await
                .map_err(internal)?
                .into_inner();
            return mimir_emit(&r.json);
        }
        self.lens_embedded(
            "symbol_identity",
            serde_json::json!({ "repo": repo, "limit": args.limit.unwrap_or(0) }),
        )
        .await
    }

    #[tool(
        description = "Mímir lens — UNDOCUMENTED API: the code room's nodes (effectively public, no doc) × \
        git_heat_facts(commits_90d/authors). Public API with no docs, in files that are actively \
        churning, ranked by recent churn × author breadth — the undocumented surface that is also \
        moving. `min_commits_90d` churn floor defaults to 2."
    )]
    async fn mimir_undoc_api(
        &self,
        Parameters(args): Parameters<UndocApiArgs>,
    ) -> Result<CallToolResult, McpError> {
        let repo = args.repo.clone().unwrap_or_default();
        let min = args.min_commits_90d.unwrap_or(0);
        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
                .undoc_api(pb::UndocApiQuery {
                    repo,
                    min_commits_90d: min,
                    limit: args.limit.unwrap_or(0),
                })
                .await
                .map_err(internal)?
                .into_inner();
            return mimir_emit(&r.json);
        }
        self.lens_embedded(
            "undoc_api",
            serde_json::json!({ "repo": repo, "min_commits_90d": args.min_commits_90d.unwrap_or(0), "limit": args.limit.unwrap_or(0) }),
        )
        .await
    }

    #[tool(
        description = "Mímir lens — REACHABLE VULN: vuln_findings × sbom_components × the code room's CALLS. \
        A CVE in a repo's components is refuse 🔴 only if the vulnerable crate is actually CALLED; \
        present-but-unreachable is hold 🟠. The reachability gate that separates the vulnerabilities \
        that matter from the ones that merely appear in the SBOM."
    )]
    async fn mimir_reachable_vuln(
        &self,
        Parameters(args): Parameters<LensArgs>,
    ) -> Result<CallToolResult, McpError> {
        let repo = args.repo.clone().unwrap_or_default();
        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
                .reachable_vuln(pb::LensQuery {
                    repo,
                    limit: args.limit.unwrap_or(0),
                })
                .await
                .map_err(internal)?
                .into_inner();
            return mimir_emit(&r.json);
        }
        self.lens_embedded(
            "reachable_vuln",
            serde_json::json!({ "repo": repo, "limit": args.limit.unwrap_or(0) }),
        )
        .await
    }

    #[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 = "HYBRID (semantic + lexical) 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, no re-embed or git walk), THEN fuses that dense cosine-kNN ranking \
            with the persisted BM25 full-text index via Reciprocal-Rank-Fusion so rare exact \
            identifiers/error-strings AND paraphrases both surface. The repo must have been vectorized \
            first via `nornir vector index <repo>` (BM25 arm degrades to dense-only if no text index \
            exists yet). Returns ranked {file, span, score} hits, best-first by fused score. \
            (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![ContentBlock::text(resp.json)]));
        }
        #[cfg(any(feature = "embed-tract", feature = "embed-ort"))]
        {
            let embedder = self.embedder().await?;
            let (warehouse_root, workspace_root) = {
                let s = self.state.lock().await;
                (s.loaded.warehouse_root(), s.loaded.workspace_root.clone())
            };
            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(&[query.as_str()])?;
                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());
                // HYBRID (RRF): fuse the dense cosine kNN with the persisted BM25
                // lexical index so the shipped search returns the FUSED ranking —
                // the productionized `nornir.mimir.search.hybrid_rrf_*` bench.
                // Reuses both live retrievers; degrades to dense-only if no BM25
                // text index is built for this workspace yet.
                let idx = index::Index::open(&workspace_root)?;
                nornir::vector::store::hybrid_search(
                    &wh, &idx, &repo, sha, &mp, &query, &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 = "Graph-RAG CONTEXT SLICES (Mimir): the LLM-injectable code-slice surface. \
            Embeds the query, vector-matches <repo>'s materialized embeddings, and JOINs the \
            embedding_manifest span ⋈ the code room's nodes (item_kind / item_name / visibility / signature) \
            into ranked, AST-clean slices ready for prompt injection — no disk re-search, no \
            re-embed. Optionally pass `seeds` (changed symbol names) + `hops` to TRIM to a \
            CALLS change-radius (blast radius of a change), annotating each slice with its hop \
            distance; `include_callers` also follows reverse (caller) edges. Returns a JSON array of \
            {repo, file, start_line, end_line, score, content_hash, radius_hops, symbols[]}. Runs \
            EMBEDDED (unset NORNIR_SERVER); needs an embedder feature."
    )]
    async fn knowledge_context(
        &self,
        Parameters(args): Parameters<KnowledgeContextArgs>,
    ) -> Result<CallToolResult, McpError> {
        if server_target().is_some() {
            return Err(McpError::invalid_params(
                "knowledge_context runs embedded only (no context gRPC endpoint yet); unset \
                 NORNIR_SERVER to slice the local warehouse"
                    .to_string(),
                None,
            ));
        }
        #[cfg(any(feature = "embed-tract", feature = "embed-ort"))]
        {
            // The slice JOIN needs the symbol / call neighbourhood of the hits:
            // the code room's reader.
            let label = format!(
                "knowledge_context `{}` in {}@{} (k={}, seeds={:?}, hops={}, callers={})",
                args.query,
                args.repo,
                if args.sha.is_empty() { "latest" } else { args.sha.as_str() },
                args.limit.unwrap_or(8),
                args.seeds,
                args.hops.unwrap_or(1),
                args.include_callers,
            );
            let params = json!({
                "query": args.query,
                "repo": args.repo,
                "sha": args.sha,
                "limit": args.limit.unwrap_or(8),
                "seeds": args.seeds,
                "hops": args.hops.unwrap_or(1),
                "include_callers": args.include_callers,
            });
            ok_json(&self.reader_embedded("Knowledge.Context", label, params).await?)
        }
        #[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![ContentBlock::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![ContentBlock::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![ContentBlock::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![ContentBlock::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: <repo>'s recorded bench-run history from the warehouse `bench_runs` \
        table (one BenchRun per run, newest the way it was persisted). Works embedded (own \
        warehouse) or via the server (Bench.History) when NORNIR_SERVER is set."
    )]
    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);
        }
        // Embedded mode: read the warehouse `bench_runs` table directly (read-only
        // + lock-tolerant, matching the `test_history` embedded path) — NOT the
        // legacy on-disk `bench_history.jsonl`. `Ops.RunBench` only writes the
        // warehouse, so jsonl reads were stale/empty.
        let warehouse_root = {
            let s = self.state.lock().await;
            if !s.loaded.nornir.repo.contains_key(&args.repo) {
                return Err(McpError::invalid_params(
                    format!("no [repo.{}]", args.repo),
                    None,
                ));
            }
            s.loaded.warehouse_root()
        };
        let repo = args.repo.clone();
        let runs = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<bench::BenchRun>> {
            use nornir::warehouse::BenchFilter;
            use nornir::warehouse::iceberg::IcebergWarehouse;
            let wh = IcebergWarehouse::open_read_only(&warehouse_root)?;
            wh.block_on(wh.query_bench_runs_async(&BenchFilter::for_repo(repo)))
        })
        .await
        .map_err(internal)?
        .map_err(internal)?;
        ok_json(&runs)
    }

    #[tool(
        description = "Warehouse SQL: run arbitrary read-only SQL over the Iceberg warehouse \
        tables via DataFusion (native iceberg-datafusion TableProviders → projection + filter \
        pushdown). Table names are the warehouse table names (bench_runs, test_results, \
        coverage, surface_coverage, build_provenance, dep_graph_edges, \
        release_lineage, …). Supports SELECT … WHERE, JOIN across tables, and aggregates; returns \
        the result rows as a JSON array. Runs EMBEDDED against the local warehouse (read-only, \
        lock-tolerant via a copied-aside snapshot when a live server holds the catalog); there is \
        no SQL gRPC yet, so unset NORNIR_SERVER to use it."
    )]
    async fn warehouse_query(
        &self,
        Parameters(args): Parameters<WarehouseSqlArgs>,
    ) -> Result<CallToolResult, McpError> {
        if server_target().is_some() {
            return Err(McpError::invalid_params(
                "warehouse_query runs embedded only (no SQL gRPC endpoint); unset NORNIR_SERVER \
                 to query the local warehouse"
                    .to_string(),
                None,
            ));
        }
        let warehouse_root = {
            let s = self.state.lock().await;
            s.loaded.warehouse_root()
        };
        let sql = args.sql.clone();
        // Read-only + lock-tolerant open on a blocking thread (the warehouse
        // owns a tokio runtime and does its own block_on), matching bench_history.
        let json = tokio::task::spawn_blocking(move || -> anyhow::Result<String> {
            use nornir::warehouse::iceberg::IcebergWarehouse;
            use nornir::warehouse::sql;
            let wh = IcebergWarehouse::open_read_only(&warehouse_root)?;
            let batches = sql::query(&wh, &sql)?;
            sql::format_json(&batches)
        })
        .await
        .map_err(internal)?
        .map_err(internal)?;
        ok_json_text(json)
    }

    #[tool(
        description = "Warehouse schema DISCOVERY: the companion to warehouse_query. With no \
        args, returns the JSON array of every queryable warehouse table name (the valid FROM \
        targets — bench_runs, test_results, coverage, build_provenance, \
        dep_graph_edges, release_lineage, …). With `table` set, returns that table's columns as a \
        JSON array of {name, data_type, nullable} so a consumer can see exactly what a SELECT may \
        reference BEFORE writing SQL. Catalog-metadata only (no Parquet scan) — cheap + read-only. \
        Runs EMBEDDED against the local warehouse (like warehouse_query); unset NORNIR_SERVER."
    )]
    async fn warehouse_tables(
        &self,
        Parameters(args): Parameters<WarehouseTablesArgs>,
    ) -> Result<CallToolResult, McpError> {
        if server_target().is_some() {
            return Err(McpError::invalid_params(
                "warehouse_tables runs embedded only (no SQL gRPC endpoint); unset NORNIR_SERVER \
                 to inspect the local warehouse schema"
                    .to_string(),
                None,
            ));
        }
        let warehouse_root = {
            let s = self.state.lock().await;
            s.loaded.warehouse_root()
        };
        let table = args.table.clone();
        // Read-only + lock-tolerant open on a blocking thread (the warehouse owns
        // its own runtime + block_on), matching warehouse_query.
        let json = tokio::task::spawn_blocking(move || -> anyhow::Result<String> {
            use nornir::warehouse::iceberg::IcebergWarehouse;
            use nornir::warehouse::sql;
            let wh = IcebergWarehouse::open_read_only(&warehouse_root)?;
            match table {
                Some(t) => Ok(serde_json::to_string(&sql::describe_table(&wh, &t)?)?),
                None => Ok(serde_json::to_string(&sql::list_tables(&wh)?)?),
            }
        })
        .await
        .map_err(internal)?
        .map_err(internal)?;
        ok_json_text(json)
    }

    #[tool(
        description = "Mimir SQL TEMPLATES: run a canned code-graph analysis over the warehouse \
        WITHOUT hand-writing SQL. With no `template`, returns the menu (name/description/params). \
        With `template` + `params`, renders the parameterised SELECT (quote-escaped, injection-safe) \
        and runs it embedded, returning the result rows as a JSON array. Templates: \
        `def-use` (symbol definition + every call site; params repo, symbol), \
        `change-radius` (symbols in a changed file + their callers = 1-hop blast radius; params \
        repo, file), `call-chain` (direct callers + callees of a symbol; params repo, symbol), \
        `table-access-lineage` (which functions read/write a warehouse table; param table). Set \
        `render_only` to get the SQL text without executing. Embedded only — unset NORNIR_SERVER."
    )]
    async fn mimir_sql_template(
        &self,
        Parameters(args): Parameters<MimirSqlTemplateArgs>,
    ) -> Result<CallToolResult, McpError> {
        // The canned templates (def-use, change-radius, call-chain,
        // table-access-lineage) are questions to the code graph: the code room's
        // reader answers them. Raw SQL (`warehouse_query`) is unchanged.
        let label = format!(
            "mimir_sql_template {:?} (params {:?}, render_only {})",
            args.template, args.params, args.render_only,
        );
        let params = json!({
            "template": args.template,
            "params": args.params,
            "render_only": args.render_only,
        });
        ok_json(&self.reader_embedded("Mimir.SqlTemplate", label, params).await?)
    }

    #[tool(
        description = "Test: <repo>'s recorded test-run history — one RunSummary per run \
        (run_id, ts_micros, pass/fail/ignored/stalled/skipped counts, total, green flag), \
        newest-first. The SAME per-run data `nornir test history <repo>` prints. Optional \
        limit caps to the N most recent runs. Works embedded (own warehouse) or via the \
        server (Viz.TestResults) when NORNIR_SERVER is set."
    )]
    async fn test_history(
        &self,
        Parameters(args): Parameters<TestHistoryArgs>,
    ) -> Result<CallToolResult, McpError> {
        use nornir::warehouse::test_results::{runs_to_json, summarize_runs};
        let limit = args.limit.unwrap_or(0);
        // Client mode: the server owns the warehouse lock — pull every
        // `test_results` row over Viz.TestResults, then scope to the repo (or
        // run id) + summarize locally. The summarize/scope is PURE, identical to
        // the embedded path below, so the two modes return byte-identical JSON.
        if server_target().is_some() {
            let (channel, bearer) = mcp_connect().await?;
            let mut c = pb::viz_client::VizClient::with_interceptor(channel, mcp_auth(bearer))
                .max_decoding_message_size(64 << 20);
            let resp = c
                .test_results(pb::VizTimelineRequest {
                    workspace: String::new(),
                })
                .await
                .map_err(internal)?
                .into_inner();
            let all: Vec<nornir::warehouse::test_results::TestResultRow> =
                serde_json::from_str(&resp.json).map_err(internal)?;
            let summaries = test_history_summaries(&all, &args.repo, limit);
            return ok_json_text(runs_to_json(&summaries));
        }
        // Embedded mode: read the warehouse directly (read-only + lock-tolerant,
        // matching the `nornir test history` CLI), scope + summarize.
        let warehouse_root = {
            let s = self.state.lock().await;
            s.loaded.warehouse_root()
        };
        let repo = args.repo.clone();
        let json = tokio::task::spawn_blocking(move || -> anyhow::Result<String> {
            use nornir::warehouse::iceberg::IcebergWarehouse;
            use nornir::warehouse::test_results::{TestSelector, query_test_results};
            let wh = IcebergWarehouse::open_read_only(&warehouse_root)?;
            let mut rows =
                wh.block_on(query_test_results(&wh, &TestSelector::Repo(repo.clone())))?;
            if rows.is_empty() {
                rows = wh.block_on(query_test_results(&wh, &TestSelector::Run(repo.clone())))?;
            }
            let summaries = summarize_runs(&rows);
            let capped: Vec<_> = if limit > 0 {
                summaries.into_iter().take(limit).collect()
            } else {
                summaries
            };
            Ok(runs_to_json(&capped))
        })
        .await
        .map_err(internal)?
        .map_err(internal)?;
        ok_json_text(json)
    }

    #[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![ContentBlock::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![ContentBlock::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![ContentBlock::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![ContentBlock::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)
            .with_path_dep_cloner(&nornir::security::docs_path_dep_cloner);
        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![ContentBlock::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![ContentBlock::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)
            .with_path_dep_cloner(&nornir::security::docs_path_dep_cloner);
        nornir::docs::render_check_all(&layout, &ctx).map_err(internal)?;
        Ok(CallToolResult::success(vec![ContentBlock::text(format!(
            "ok: every doc in {} matches its source",
            args.repo
        ))]))
    }

    #[tool(
        description = "Docs: install a tracked pre-commit hook for <repo> (.githooks/pre-commit + core.hooksPath) that re-renders managed markdown (markdown-only, never blocks) on every commit. Local-only; idempotent."
    )]
    async fn docs_install_hooks(
        &self,
        Parameters(args): Parameters<RepoArg>,
    ) -> Result<CallToolResult, McpError> {
        // Local-only: edits the local checkout's `.githooks/` + git config. No
        // server RPC (the server can't install a hook into a dev's checkout).
        let s = self.state.lock().await;
        let (root, _repo) = repo_ctx(&s, &args.repo).map_err(internal)?;
        let report = nornir::docs::install_hooks(&root, &args.repo).map_err(internal)?;
        let body = serde_json::json!({
            "repo": args.repo,
            "hook_path": report.hook_path,
            "hook_changed": report.hook_changed,
            "hookspath_changed": report.hookspath_changed,
            "hooks_path": nornir::docs::hooks::HOOKS_DIR,
        });
        Ok(CallToolResult::success(vec![ContentBlock::text(
            serde_json::to_string_pretty(&body).unwrap_or_default(),
        )]))
    }

    #[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![ContentBlock::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)
                        .with_path_dep_cloner(&nornir::security::docs_path_dep_cloner);
                    // 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![ContentBlock::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)
                        .with_path_dep_cloner(&nornir::security::docs_path_dep_cloner);
                    // 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![ContentBlock::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)
            .with_path_dep_cloner(&nornir::security::docs_path_dep_cloner);
        nornir::docs::render_check_all(&layout, &ctx).map_err(internal)?;
        Ok(CallToolResult::success(vec![ContentBlock::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_2021, $r:expr_2021) => {
                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)
                    .with_path_dep_cloner(&nornir::security::docs_path_dep_cloner);
                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);
        }
        // Plan #5: the heavy test-matrix gate. Opt-in (`[gates].tests`); runs the
        // matrix, folds the fresh rows with the repo's persisted history, fails on
        // red/regression, and persists the fresh rows. Heavy → release/on-request
        // only (this gate call), never on workspace load. Run off the executor
        // (spawn_blocking) after releasing the state lock.
        if g.tests {
            let warehouse_root = s.loaded.warehouse_root();
            let root = root.clone();
            let repo_name = args.repo.clone();
            drop(s);
            let r = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
                use nornir::warehouse::test_results::{
                    TestSelector, append_test_results_blocking, query_test_results,
                };
                let wh = IcebergWarehouse::open(&warehouse_root)?;
                let history = wh
                    .block_on(query_test_results(
                        &wh,
                        &TestSelector::Repo(repo_name.clone()),
                    ))
                    .unwrap_or_default();
                let aspects = nornir::test_matrix::Aspect::DEFAULT.to_vec();
                let fresh = release::gate::run_tests_gate(&root, &history, &aspects)?;
                let _ = append_test_results_blocking(&wh, &fresh);
                Ok(())
            })
            .await
            .map_err(internal)?;
            match r {
                Ok(()) => passed.push("tests".into()),
                Err(e) => {
                    failed.push(serde_json::json!({"name": "tests", "error": format!("{e:#}")}))
                }
            }
            let body = serde_json::json!({"repo": args.repo, "passed": passed, "failed": failed});
            return Ok(CallToolResult::success(vec![ContentBlock::text(
                serde_json::to_string_pretty(&body).unwrap(),
            )]));
        }
        let body = serde_json::json!({"repo": args.repo, "passed": passed, "failed": failed});
        Ok(CallToolResult::success(vec![ContentBlock::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 = the code room's functions − those a test reaches over CALLS), 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, room) = {
            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, s.code_room.clone())
        };
        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();
            // the surface is the code room's
            let reach = nornir::code_room::Reach::new(&room, &wh);
            let state =
                nornir::autonom::gather_in(&reach, &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, room) = {
            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,
                s.code_room.clone(),
            )
        };
        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();
            // the surface is the code room's
            let reach = nornir::code_room::Reach::new(&room, &wh);
            let state =
                nornir::autonom::gather_in(&reach, &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![ContentBlock::text(format!(
            "ok: {summary}"
        ))]))
    }

    #[tool(
        description = "Release: advisory `release doctor` report across the whole constellation — \
        dirty trees (real local git status), external-dependency version skew (target vs each repo \
        with bump hints), path-override safety, promotability, and blast radius. Read-only, no \
        mutation; the 'see it and get hints first' tier before the release gates. Embedded/local \
        only (reads the local checkouts). Args: optional forbid ([\"arrow=56\"]), optional json, \
        optional semantic_blast (true = also what each dirty repo's uncommitted change breaks in \
        its dependents: a dry-run dump of the dirty tree by the nightly driver + the code room; \
        refuses, by state, when that cannot be derived)."
    )]
    async fn release_doctor(
        &self,
        Parameters(args): Parameters<DoctorArgs>,
    ) -> Result<CallToolResult, McpError> {
        if server_target().is_some() {
            return Err(McpError::invalid_params(
                "release_doctor is an embedded/local tool — it reads the local checkouts' git \
                 trees; run nornir-mcp without NORNIR_SERVER, or use `nornir release doctor` on \
                 the host"
                    .to_string(),
                None,
            ));
        }
        let (repos, forbid, json) = {
            let s = self.state.lock().await;
            let repos: Vec<(String, PathBuf)> = s
                .loaded
                .nornir
                .repo
                .keys()
                .map(|name| {
                    (
                        name.clone(),
                        s.loaded.nornir.repo_dir_for(&s.loaded.workspace_root, name),
                    )
                })
                .filter(|(_, p)| p.join("Cargo.toml").exists())
                .collect();
            (repos, args.forbid.clone(), args.json)
        };
        let room = if args.semantic_blast { Some(self.state.lock().await.code_room.clone()) } else { None };
        let report = tokio::task::spawn_blocking(move || -> anyhow::Result<_> {
            let mut policy = release::doctor::DepPolicy::default();
            for f in &forbid {
                if let Some((c, v)) = f.split_once('=') {
                    policy.forbidden.push(release::doctor::ForbiddenDep {
                        crate_name: c.to_string(),
                        version: v.to_string(),
                    });
                }
            }
            let Some(room) = room else {
                return release::doctor::run(&repos, &policy);
            };
            // the pending change of every dirty repo, through the code room
            let reach = nornir::code_room::Reach::room_only(&room);
            let driver = nornir::code_room::reader::TreeDriver::from_env();
            let (report, audit) =
                release::doctor::run_with_warehouse_semantic_blast(&repos, &policy, &reach, &driver)?;
            eprintln!("release_doctor: semantic blast — {}", audit.note);
            Ok(report)
        })
        .await
        .map_err(internal)?
        .map_err(internal)?;
        if json {
            ok_json(&report)
        } else {
            ok_json_text(release::doctor::format_report(&report))
        }
    }

    #[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![ContentBlock::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![ContentBlock::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));

The file goes on; the console shows all of it.