use std::{borrow::Cow, path::PathBuf, sync::Arc};

use rmcp::{
    handler::server::{
        router::tool::{AsyncTool, ToolBase},
        tool::ToolRouter,
    },
    model::*,
    tool_handler, ErrorData, ServerHandler, ServiceExt,
};
use schemars::JsonSchema;
use serde::Deserialize;
use tracing::{debug, warn};

use crate::{api::AppState, cache::NclCache};

// ── Error ───────────────────────────────────────────────────────────────────────

struct ToolError(String);

impl From<ToolError> for ErrorData {
    fn from(e: ToolError) -> ErrorData {
        ErrorData::internal_error(e.0, None)
    }
}

impl From<serde_json::Error> for ToolError {
    fn from(e: serde_json::Error) -> Self {
        ToolError(e.to_string())
    }
}

impl From<String> for ToolError {
    fn from(s: String) -> Self {
        ToolError(s)
    }
}

// ── Project context
// ─────────────────────────────────────────────────────────────

struct ProjectCtx {
    root: PathBuf,
    cache: Arc<NclCache>,
    import_path: Option<String>,
}

// ── Tool parameter types
// ────────────────────────────────────────────────────────

#[derive(Deserialize, JsonSchema, Default)]
struct NoInput {}

#[derive(Deserialize, JsonSchema, Default)]
struct ProjectParam {
    /// Project slug. Omit to use the default (single-project) project.
    project: Option<String>,
}

#[derive(Deserialize, JsonSchema, Default)]
struct GuidesInput {
    /// Project slug. Omit to use the default project.
    project: Option<String>,
    /// Actor context for policy derivation: developer | agent | ci | admin.
    /// Omit to use the detected actor.
    actor: Option<String>,
}

#[derive(Deserialize, JsonSchema, Default)]
struct ApiCatalogInput {
    /// Filter by actor: developer | agent | ci | admin. Omit to return all
    /// routes.
    actor: Option<String>,
    /// Filter by tag (e.g. "ontology", "projects", "search"). Omit for all
    /// tags.
    tag: Option<String>,
    /// Filter by auth level: none | viewer | admin. Omit for all auth levels.
    auth: Option<String>,
}

#[derive(Deserialize, JsonSchema, Default)]
struct FileVersionsInput {
    /// Project slug. Omit to use the default project.
    project: Option<String>,
}

#[derive(Deserialize, JsonSchema, Default)]
struct SearchInput {
    /// Full-text search query across ontology nodes, ADRs, and reflection
    /// modes.
    query: String,
    /// Project slug. Omit to use the default project.
    project: Option<String>,
}

#[derive(Deserialize, JsonSchema, Default)]
struct GetItemInput {
    /// Item id or partial filename stem (e.g. `adr-001`, `plan-mode`).
    id: String,
    /// Project slug. Omit to use the default project.
    project: Option<String>,
}

#[derive(Deserialize, JsonSchema, Default)]
struct DisciplineInput {
    /// Discipline (work area) to mount — e.g. `coding`, `positioning`,
    /// `difusion`.
    discipline: String,
    /// Project slug. Omit to use the default project.
    project: Option<String>,
}

#[derive(Deserialize, JsonSchema, Default)]
struct BacklogInput {
    /// Project slug. Omit to use the default project.
    project: Option<String>,
    /// Filter by status: `Open` | `InProgress` | `Done` | `Cancelled`.
    status: Option<String>,
}

#[derive(Deserialize, JsonSchema, Default)]
struct GetInput {
    /// Item id as returned by `ontoref_search` (e.g. `backend-agnostic-core`,
    /// `adr-001`, `plan-mode`).
    id: String,
    /// Kind of item: `"node"` | `"adr"` | `"mode"`.
    kind: String,
    /// Project slug. Omit to use the current/default project.
    project: Option<String>,
}

#[derive(Deserialize, JsonSchema, Default)]
struct SetProjectInput {
    /// Project slug to set as the current default for this MCP session.
    project: String,
}

#[derive(Deserialize, JsonSchema, Default)]
struct BacklogOpInput {
    /// Operation: `"add"` to create a new item, `"update_status"` to change
    /// status.
    operation: String,
    /// Project slug. Omit to use the current/default project.
    project: Option<String>,
    // ── update_status fields ──
    /// Item id (e.g. `bl-001`). Required for `update_status`.
    id: Option<String>,
    /// New status: `Open` | `InProgress` | `Done` | `Cancelled`. Required for
    /// `update_status`.
    status: Option<String>,
    // ── add fields ──
    /// Item title. Required for `add`.
    title: Option<String>,
    /// Item kind: `Feature` | `Bug` | `Chore` | `Research`. Required for `add`.
    kind: Option<String>,
    /// Item priority: `Critical` | `High` | `Medium` | `Low`. Required for
    /// `add`.
    priority: Option<String>,
    /// Optional detail or acceptance criteria for `add`.
    detail: Option<String>,
}

#[derive(Deserialize, JsonSchema, Default)]
struct ChronicleInput {
    /// Project slug. Omit to use the default project.
    project: Option<String>,
    /// ISO date lower bound (inclusive), e.g. "2026-01-01".
    since: Option<String>,
    /// Entry kind filter: "adr" | "migration".
    kind: Option<String>,
    /// Domain filter (e.g. "coding", "difusion").
    domain: Option<String>,
}

#[derive(Deserialize, JsonSchema, Default)]
struct RoadmapQueryInput {
    /// Project slug. Omit to use the default project.
    project: Option<String>,
    /// Status filter: Open|InProgress|Done|Cancelled.
    status: Option<String>,
    /// Horizon bucket filter: now|next|later.
    horizon: Option<String>,
}

#[derive(Deserialize, JsonSchema, Default)]
struct CriteriaInput {
    /// Project slug. Omit to use the default project.
    project: Option<String>,
    /// Kind filter: "normative" (all current criteria are normative).
    kind: Option<String>,
}

#[derive(Deserialize, JsonSchema, Default)]
struct PanelsInput {
    /// Project slug. Omit to use the default project.
    project: Option<String>,
    /// Panel kind filter: Timeline|Gantt|Graph|Table|Board.
    kind: Option<String>,
}

#[derive(Deserialize, JsonSchema, Default)]
struct QaListInput {
    /// Project slug. Omit to use the default project.
    project: Option<String>,
    /// Optional substring filter on question text.
    filter: Option<String>,
}

#[derive(Deserialize, JsonSchema, Default)]
struct QaAddInput {
    /// The question text.
    question: String,
    /// The answer text. May be left empty to fill in later.
    answer: Option<String>,
    /// Actor recording this entry. Defaults to `"agent"`.
    actor: Option<String>,
    /// Optional tags for categorisation.
    tags: Option<Vec<String>>,
    /// Optional references to ontology node ids or ADR ids.
    related: Option<Vec<String>>,
    /// Project slug. Omit to use the default project.
    project: Option<String>,
}

#[derive(Deserialize, JsonSchema, Default)]
struct BookmarkListInput {
    /// Project slug. Omit to use the default project.
    project: Option<String>,
    /// Optional substring filter on node_id or title.
    filter: Option<String>,
}

#[derive(Deserialize, JsonSchema, Default)]
struct BookmarkAddInput {
    /// Ontology node id to bookmark (e.g. `"add-project"`).
    node_id: String,
    /// Kind of the result: `"node"`, `"adr"`, or `"mode"`.
    kind: Option<String>,
    /// Human-readable title of the bookmarked node.
    title: String,
    /// Ontology level: `Axiom`, `Tension`, `Practice`, `Project`. May be empty.
    level: Option<String>,
    /// Search term that produced this result.
    term: Option<String>,
    /// Actor saving the bookmark. Defaults to `"agent"`.
    actor: Option<String>,
    /// Optional tags for categorisation.
    tags: Option<Vec<String>>,
    /// Project slug. Omit to use the default project.
    project: Option<String>,
}

#[derive(Deserialize, JsonSchema, Default)]
struct ActionListInput {
    /// Project slug. Omit to use the default project.
    project: Option<String>,
}

#[derive(Deserialize, JsonSchema, Default)]
struct ActionAddInput {
    /// Short unique slug for the mode file (e.g. `gen-docs`).
    id: String,
    /// Human-readable label shown in the UI.
    label: String,
    /// Natural-language trigger description.
    trigger: String,
    /// Steps as an ordered list of strings.
    steps: Vec<String>,
    /// Icon hint: `"book-open"` | `"refresh"` | `"code"` | `"bolt"`.
    icon: Option<String>,
    /// Category: `"docs"` | `"sync"` | `"analysis"` | `"test"`.
    category: Option<String>,
    /// Actors who can run this action: `"developer"` | `"agent"` | `"ci"`.
    actors: Option<Vec<String>>,
    /// Project slug. Omit to use the default project.
    project: Option<String>,
}

// ── Config surface input types
// ──────────────────────────────────────────────

#[derive(Deserialize, JsonSchema, Default)]
struct ConfigReadInput {
    /// Project slug. Omit to use the default project.
    project: Option<String>,
    /// Config section id (e.g. `"server"`). Omit to return all sections.
    section: Option<String>,
}

#[derive(Deserialize, JsonSchema, Default)]
struct ConfigUpdateInput {
    /// Project slug. Omit to use the default project.
    project: Option<String>,
    /// Section id to mutate (e.g. `"server"`).
    section: String,
    /// Key/value pairs to write into the override layer.
    values: serde_json::Value,
    /// When true (default), return the proposed diff without writing anything.
    dry_run: Option<bool>,
    /// Human-readable reason for this change (stored in the override audit
    /// trail).
    reason: Option<String>,
}

// ── Server ──────────────────────────────────────────────────────────────────────

#[derive(Clone)]
pub struct OntoreServer {
    state: AppState,
    tool_router: ToolRouter<Self>,
}

impl OntoreServer {
    pub fn new(state: AppState) -> Self {
        Self {
            state,
            tool_router: Self::tool_router(),
        }
    }

    fn tool_router() -> ToolRouter<Self> {
        ToolRouter::new()
            .with_async_tool::<HelpTool>()
            .with_async_tool::<ListProjectsTool>()
            .with_async_tool::<SetProjectTool>()
            .with_async_tool::<SearchTool>()
            .with_async_tool::<GetTool>()
            .with_async_tool::<ViewListTool>()
            .with_async_tool::<ViewMountTool>()
            .with_async_tool::<DescribeProjectTool>()
            .with_async_tool::<ProjectStatusTool>()
            .with_async_tool::<ListAdrsTool>()
            .with_async_tool::<GetAdrTool>()
            .with_async_tool::<ListOntologyExtensionsTool>()
            .with_async_tool::<GetOntologyExtensionTool>()
            .with_async_tool::<ListModesTool>()
            .with_async_tool::<GetModeTool>()
            .with_async_tool::<GetNodeTool>()
            .with_async_tool::<GetBacklogTool>()
            .with_async_tool::<BacklogOpTool>()
            .with_async_tool::<GetConstraintsTool>()
            .with_async_tool::<QaListTool>()
            .with_async_tool::<QaAddTool>()
            .with_async_tool::<BookmarkListTool>()
            .with_async_tool::<BookmarkAddTool>()
            .with_async_tool::<ActionListTool>()
            .with_async_tool::<ActionAddTool>()
            .with_async_tool::<ValidateAdrsTool>()
            .with_async_tool::<ValidateProjectTool>()
            .with_async_tool::<ImpactTool>()
            .with_async_tool::<GuidesTool>()
            .with_async_tool::<ApiCatalogTool>()
            .with_async_tool::<FileVersionsTool>()
            .with_async_tool::<ProjectConfigTool>()
            .with_async_tool::<ConfigCoherenceTool>()
            .with_async_tool::<ConfigQuickrefTool>()
            .with_async_tool::<ConfigUpdateTool>()
            .with_async_tool::<VaultStatusTool>()
            .with_async_tool::<PositioningGetTool>()
            .with_async_tool::<PositioningGetValuePropTool>()
            .with_async_tool::<PositioningAuditTool>()
            .with_async_tool::<DomainListTool>()
            .with_async_tool::<DomainConsumersTool>()
            .with_async_tool::<DomainShowTool>()
            .with_async_tool::<ConfigViewTool>()
            .with_async_tool::<TierStatusMcpTool>()
            .with_async_tool::<TierNextMcpTool>()
            .with_async_tool::<ChronicleListTool>()
            .with_async_tool::<RoadmapListTool>()
            .with_async_tool::<CriteriaListTool>()
            .with_async_tool::<PanelsListTool>()
    }

    fn project_ctx(&self, slug: Option<&str>) -> ProjectCtx {
        // Explicit slug > mcp_current_project > default project.
        let current = self
            .state
            .mcp_current_project
            .read()
            .ok()
            .and_then(|g| g.clone());
        let effective = slug.map(str::to_string).or(current);

        #[cfg(feature = "ui")]
        if let Some(s) = effective.as_deref() {
            if let Some(ctx) = self.state.registry.get(s) {
                return ProjectCtx {
                    root: ctx.root.clone(),
                    cache: ctx.cache.clone(),
                    import_path: ctx.import_path.clone(),
                };
            }
        }
        let _ = effective;
        ProjectCtx {
            root: self.state.project_root.clone(),
            cache: self.state.cache.clone(),
            import_path: self.state.nickel_import_path.clone(),
        }
    }

    fn available_projects(&self) -> Vec<String> {
        self.state
            .registry
            .all()
            .into_iter()
            .map(|p| p.slug.clone())
            .collect()
    }
}

// ── MCP tool catalog (ADR-015)
// ─────────────────────────────────────────────────

/// All MCP tools registered via `#[ontoref_derive::onto_mcp_tool(...)]`,
/// sorted by tool name. Reads the linker-built inventory at runtime — no
/// allocation of catalog state, no daemon-state dependency.
pub fn catalog() -> Vec<&'static ontoref_ontology::McpToolEntry> {
    let mut tools: Vec<&'static ontoref_ontology::McpToolEntry> =
        inventory::iter::<ontoref_ontology::McpToolEntry>().collect();
    tools.sort_by_key(|t| t.name);
    tools
}

/// Render a single catalog entry into the JSON shape returned by the
/// `ontoref_help` tool. `constraint` is mapped to `required: bool` and an
/// optional `default` field; non-empty `kind` is surfaced as a type hint.
fn render_tool_for_help(t: &ontoref_ontology::McpToolEntry) -> serde_json::Value {
    let params: Vec<serde_json::Value> = t
        .params
        .iter()
        .map(|p| {
            let mut entry = serde_json::Map::new();
            entry.insert("name".into(), serde_json::Value::String(p.name.into()));
            if !p.kind.is_empty() {
                entry.insert("kind".into(), serde_json::Value::String(p.kind.into()));
            }
            entry.insert(
                "required".into(),
                serde_json::Value::Bool(p.constraint == "required"),
            );
            if let Some(default) = p.constraint.strip_prefix("default=") {
                entry.insert("default".into(), serde_json::Value::String(default.into()));
            }
            if !p.description.is_empty() {
                entry.insert(
                    "description".into(),
                    serde_json::Value::String(p.description.into()),
                );
            }
            serde_json::Value::Object(entry)
        })
        .collect();

    serde_json::json!({
        "name": t.name,
        "description": t.description,
        "category": t.category,
        "params": params,
    })
}

// ── Tool: list_projects
// ─────────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_list_projects",
    description = "List all available projects. In single-project mode returns the current \
                   project name.",
    category = "discovery"
)]
struct ListProjectsTool;

impl ToolBase for ListProjectsTool {
    type Parameter = NoInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_list_projects".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "List all available projects. In single-project mode returns the current project name."
                .into(),
        )
    }

    fn input_schema() -> Option<Arc<JsonObject>> {
        None
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for ListProjectsTool {
    async fn invoke(
        service: &OntoreServer,
        _param: NoInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "list_projects");
        let projects = service.available_projects();
        Ok(serde_json::json!({ "projects": projects }))
    }
}

// ── Tool: search
// ────────────────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_search",
    description = "Free-text search across nodes, ADRs, and modes. Returns kind+id for use with \
                   ontoref_get.",
    category = "discovery",
    params = "query:string:required:Free-text search query; project:string:optional:Project slug"
)]
struct SearchTool;

impl ToolBase for SearchTool {
    type Parameter = SearchInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_search".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "Free-text search across ontology nodes, ADRs, and reflection modes. Returns ranked \
             results with kind, title, and description."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for SearchTool {
    async fn invoke(
        service: &OntoreServer,
        param: SearchInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "search", query = %param.query, project = ?param.project);
        let ctx = service.project_ctx(param.project.as_deref());
        let results = crate::search::search_project(
            &ctx.root,
            &ctx.cache,
            ctx.import_path.as_deref(),
            &param.query,
        )
        .await;
        serde_json::to_value(results).map_err(ToolError::from)
    }
}

// ── Tool: view_list / view_mount (ADR-046 — substrate views as an MCP surface)
// ──────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_view_list",
    description = "List the declared substrate views (id, discipline, context, learns_from).",
    category = "discovery",
    params = "project:string:optional:Project slug"
)]
struct ViewListTool;

impl ToolBase for ViewListTool {
    type Parameter = ProjectParam;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_view_list".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some("List the declared substrate views (ADR-046).".into())
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for ViewListTool {
    async fn invoke(
        service: &OntoreServer,
        param: ProjectParam,
    ) -> Result<serde_json::Value, ToolError> {
        let ctx = service.project_ctx(param.project.as_deref());
        Ok(crate::views::list_views_json(
            &ctx.root,
            ctx.import_path.as_deref(),
        ))
    }
}

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_view_mount",
    description = "Mount a discipline's substrate view: the composed recipe (declared prior plus \
                   learned tuning) of which levels to load, ordered by salience, with out-of-repo \
                   levels resolved by witness. The agent-facing provisioning surface.",
    category = "discovery",
    params = "discipline:string:required:Discipline/work area (e.g. coding, positioning); \
              project:string:optional:Project slug"
)]
struct ViewMountTool;

impl ToolBase for ViewMountTool {
    type Parameter = DisciplineInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_view_mount".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "Mount a discipline's substrate view — composed recipe, prior plus learned tuning \
             (ADR-046)."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for ViewMountTool {
    async fn invoke(
        service: &OntoreServer,
        param: DisciplineInput,
    ) -> Result<serde_json::Value, ToolError> {
        let ctx = service.project_ctx(param.project.as_deref());
        match crate::views::compose_discipline(
            &ctx.root,
            ctx.import_path.as_deref(),
            &param.discipline,
        ) {
            Ok(v) => Ok(v),
            Err(crate::views::MountError::NotFound(d)) => {
                Err(ToolError(format!("no substrate view for discipline '{d}'")))
            }
            Err(crate::views::MountError::Multiple(contexts)) => Err(ToolError(format!(
                "discipline has multiple views; specify context (available: {})",
                contexts.join(", ")
            ))),
        }
    }
}

// ── Tool: describe_project
// ──────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_describe",
    description = "Architecture overview: README summary and manifest.",
    category = "discovery",
    params = "project:string:optional:Project slug"
)]
struct DescribeProjectTool;

impl ToolBase for DescribeProjectTool {
    type Parameter = ProjectParam;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_describe".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "Describe a project: README summary, manifest layers, operational modes, default \
             mode, and repo kind."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for DescribeProjectTool {
    async fn invoke(
        service: &OntoreServer,
        param: ProjectParam,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "describe_project", project = ?param.project);
        let ctx = service.project_ctx(param.project.as_deref());
        let manifest_path =
            ontoref_ontology::layout::resolve_section(&ctx.root, "ontology/manifest.ncl");

        let manifest = if manifest_path.exists() {
            match ctx
                .cache
                .export(&manifest_path, ctx.import_path.as_deref())
                .await
            {
                Ok((v, _)) => v,
                Err(e) => {
                    warn!(error = %e, "describe_project: manifest export failed");
                    serde_json::Value::Null
                }
            }
        } else {
            serde_json::Value::Null
        };

        let description = crate::ui::handlers::readme_description(&ctx.root);
        Ok(serde_json::json!({
            "root":        ctx.root.display().to_string(),
            "description": description,
            "manifest":    manifest,
        }))
    }
}

// ── Tool: list_adrs
// ─────────────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_list_adrs",
    description = "List all ADRs with id, title, status, constraint counts.",
    category = "discovery",
    params = "project:string:optional:Project slug"
)]
struct ListAdrsTool;

impl ToolBase for ListAdrsTool {
    type Parameter = ProjectParam;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_list_adrs".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "List all Architecture Decision Records (ADRs) with id, title, status, and constraint \
             counts."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for ListAdrsTool {
    async fn invoke(
        service: &OntoreServer,
        param: ProjectParam,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "list_adrs", project = ?param.project);
        let ctx = service.project_ctx(param.project.as_deref());
        let adrs_dir = ontoref_ontology::layout::resolve_section(&ctx.root, "adrs");

        let Ok(entries) = std::fs::read_dir(&adrs_dir) else {
            return Ok(serde_json::json!({ "adrs": [] }));
        };

        let mut adrs: Vec<serde_json::Value> = Vec::new();
        for entry in entries.flatten() {
            let path = entry.path();
            if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
                continue;
            }
            let rel = path
                .strip_prefix(&ctx.root)
                .unwrap_or(&path)
                .to_string_lossy()
                .to_string();

            match ctx.cache.export(&path, ctx.import_path.as_deref()).await {
                Ok((v, _)) => {
                    let hard_count = v
                        .get("constraints")
                        .and_then(|c| c.get("hard"))
                        .and_then(|h| h.as_array())
                        .map(|a| a.len())
                        .unwrap_or(0);
                    let soft_count = v
                        .get("constraints")
                        .and_then(|c| c.get("soft"))
                        .and_then(|s| s.as_array())
                        .map(|a| a.len())
                        .unwrap_or(0);
                    adrs.push(serde_json::json!({
                        "id":               v.get("id").and_then(|i| i.as_str()).unwrap_or(""),
                        "title":            v.get("title").and_then(|t| t.as_str()).unwrap_or(""),
                        "status":           v.get("status").and_then(|s| s.as_str()).unwrap_or(""),
                        "hard_constraints": hard_count,
                        "soft_constraints": soft_count,
                        "file":             rel,
                    }));
                }
                Err(e) => warn!(path = %rel, error = %e, "list_adrs: export failed"),
            }
        }

        adrs.sort_by_key(|v| v["id"].as_str().unwrap_or("").to_string());
        Ok(serde_json::json!({ "adrs": adrs }))
    }
}

// ── Tool: get_adr
// ───────────────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_get_adr",
    description = "Full ADR by id or partial stem (e.g. adr-001).",
    category = "ontology",
    params = "id:string:required:ADR id or partial stem; project:string:optional:Project slug"
)]
struct GetAdrTool;

impl ToolBase for GetAdrTool {
    type Parameter = GetItemInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_get_adr".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "Get full details of an Architecture Decision Record by id or partial filename stem \
             (e.g. `adr-001`)."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for GetAdrTool {
    async fn invoke(
        service: &OntoreServer,
        param: GetItemInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "get_adr", id = %param.id, project = ?param.project);
        let ctx = service.project_ctx(param.project.as_deref());
        let adrs_dir = ontoref_ontology::layout::resolve_section(&ctx.root, "adrs");

        let entries = std::fs::read_dir(&adrs_dir)
            .map_err(|e| ToolError(format!("adrs directory not found: {e}")))?;

        for entry in entries.flatten() {
            let path = entry.path();
            if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
                continue;
            }
            let stem = path
                .file_stem()
                .and_then(|s| s.to_str())
                .unwrap_or("")
                .to_string();
            if !stem.contains(param.id.as_str()) {
                continue;
            }
            return match ctx.cache.export(&path, ctx.import_path.as_deref()).await {
                Ok((v, _)) => Ok(v),
                Err(e) => Err(ToolError(e.to_string())),
            };
        }

        Err(ToolError(format!("ADR '{}' not found", param.id)))
    }
}

// ── Tool: list_ontology_extensions
// ──────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_list_ontology_extensions",
    description = "List extra .ontology/*.ncl files beyond core/state/gate.",
    category = "discovery",
    params = "project:string:optional:Project slug"
)]
struct ListOntologyExtensionsTool;

impl ToolBase for ListOntologyExtensionsTool {
    type Parameter = ProjectParam;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_list_ontology_extensions".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "List extra .ontology/*.ncl files beyond core.ncl, state.ncl, and gate.ncl. These are \
             project-defined domain extensions (e.g. career.ncl, personal.ncl)."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for ListOntologyExtensionsTool {
    async fn invoke(
        service: &OntoreServer,
        param: ProjectParam,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "list_ontology_extensions", project = ?param.project);
        let ctx = service.project_ctx(param.project.as_deref());
        let ontology_dir = ontoref_ontology::layout::resolve_section(&ctx.root, "ontology");

        const CORE: &[&str] = &["core.ncl", "state.ncl", "gate.ncl"];

        let Ok(entries) = std::fs::read_dir(&ontology_dir) else {
            return Ok(serde_json::json!({ "extensions": [] }));
        };

        let mut extensions: Vec<serde_json::Value> = entries
            .flatten()
            .filter_map(|e| {
                let path = e.path();
                if path.extension().and_then(|x| x.to_str()) != Some("ncl") {
                    return None;
                }
                let name = path.file_name()?.to_str()?.to_string();
                if CORE.contains(&name.as_str()) {
                    return None;
                }
                let stem = path.file_stem()?.to_str()?.to_string();
                Some(serde_json::json!({ "file": name, "id": stem }))
            })
            .collect();

        extensions.sort_by_key(|v| v["id"].as_str().unwrap_or("").to_string());
        Ok(serde_json::json!({ "extensions": extensions }))
    }
}

// ── Tool: get_ontology_extension
// ────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_get_ontology_extension",
    description = "Export a project-defined .ontology extension by stem (e.g. career, personal).",
    category = "ontology",
    params = "id:string:required:Extension stem; project:string:optional:Project slug"
)]
struct GetOntologyExtensionTool;

impl ToolBase for GetOntologyExtensionTool {
    type Parameter = GetItemInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_get_ontology_extension".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "Export a project-defined .ontology extension file by stem (e.g. \"career\", \
             \"personal\"). Returns the full exported JSON. Use ontoref_list_ontology_extensions \
             to discover available files."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for GetOntologyExtensionTool {
    async fn invoke(
        service: &OntoreServer,
        param: GetItemInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "get_ontology_extension", id = %param.id, project = ?param.project);
        let ctx = service.project_ctx(param.project.as_deref());

        const CORE: &[&str] = &["core.ncl", "state.ncl", "gate.ncl"];
        let file = if param.id.ends_with(".ncl") {
            param.id.clone()
        } else {
            format!("{}.ncl", param.id)
        };

        if file.contains('/') || file.contains("..") || CORE.contains(&file.as_str()) {
            return Err(ToolError(format!(
                "'{}' is a core file — use dedicated tools for core/state/gate",
                param.id
            )));
        }

        let path =
            ontoref_ontology::layout::resolve_section(&ctx.root, &format!("ontology/{file}"));
        if !path.exists() {
            return Err(ToolError(format!(
                "ontology extension '{}' not found",
                param.id
            )));
        }

        ctx.cache
            .export(&path, ctx.import_path.as_deref())
            .await
            .map(|(v, _)| v)
            .map_err(|e| ToolError(e.to_string()))
    }
}

// ── Tool: list_modes
// ────────────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_list_modes",
    description = "List all reflection modes with id, trigger, step count.",
    category = "discovery",
    params = "project:string:optional:Project slug"
)]
struct ListModesTool;

impl ToolBase for ListModesTool {
    type Parameter = ProjectParam;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_list_modes".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some("List all reflection modes with id, trigger description, and step count.".into())
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for ListModesTool {
    async fn invoke(
        service: &OntoreServer,
        param: ProjectParam,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "list_modes", project = ?param.project);
        let ctx = service.project_ctx(param.project.as_deref());
        let modes_dir = ontoref_ontology::layout::resolve_section(&ctx.root, "reflection/modes");

        let Ok(entries) = std::fs::read_dir(&modes_dir) else {
            return Ok(serde_json::json!({ "modes": [] }));
        };

        let mut modes: Vec<serde_json::Value> = Vec::new();
        for entry in entries.flatten() {
            let path = entry.path();
            if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
                continue;
            }
            match ctx.cache.export(&path, ctx.import_path.as_deref()).await {
                Ok((v, _)) => {
                    let step_count = v
                        .get("steps")
                        .and_then(|s| s.as_array())
                        .map(|a| a.len())
                        .unwrap_or(0);
                    modes.push(serde_json::json!({
                        "id":      v.get("id").and_then(|i| i.as_str()).unwrap_or(""),
                        "trigger": v.get("trigger").and_then(|t| t.as_str()).unwrap_or(""),
                        "steps":   step_count,
                    }));
                }
                Err(e) => {
                    let stem = path
                        .file_stem()
                        .and_then(|s| s.to_str())
                        .unwrap_or("")
                        .to_string();
                    warn!(mode = %stem, error = %e, "list_modes: export failed");
                }
            }
        }

        modes.sort_by_key(|v| v["id"].as_str().unwrap_or("").to_string());
        Ok(serde_json::json!({ "modes": modes }))
    }
}

// ── Tool: get_mode
// ──────────────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_get_mode",
    description = "Full reflection mode including all steps and preconditions.",
    category = "ontology",
    params = "id:string:required:Mode id; project:string:optional:Project slug"
)]
struct GetModeTool;

impl ToolBase for GetModeTool {
    type Parameter = GetItemInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_get_mode".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "Get full details of a reflection mode including all steps, preconditions, and \
             commands."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for GetModeTool {
    async fn invoke(
        service: &OntoreServer,
        param: GetItemInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "get_mode", id = %param.id, project = ?param.project);
        let ctx = service.project_ctx(param.project.as_deref());
        let modes_dir = ontoref_ontology::layout::resolve_section(&ctx.root, "reflection/modes");

        let entries = std::fs::read_dir(&modes_dir)
            .map_err(|_| ToolError("reflection/modes directory not found".to_string()))?;

        for entry in entries.flatten() {
            let path = entry.path();
            if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
                continue;
            }
            let stem = path
                .file_stem()
                .and_then(|s| s.to_str())
                .unwrap_or("")
                .to_string();
            if stem != param.id && !stem.contains(param.id.as_str()) {
                continue;
            }
            return match ctx.cache.export(&path, ctx.import_path.as_deref()).await {
                Ok((v, _)) => Ok(v),
                Err(e) => Err(ToolError(e.to_string())),
            };
        }

        Err(ToolError(format!("Mode '{}' not found", param.id)))
    }
}

// ── Tool: get_backlog
// ───────────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_backlog_list",
    description = "List backlog items, optionally filtered by status.",
    category = "knowledge",
    params = "project:string:optional:Project slug; \
              status:string:optional:Open|InProgress|Done|Cancelled"
)]
struct GetBacklogTool;

impl ToolBase for GetBacklogTool {
    type Parameter = BacklogInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_backlog_list".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "Get project backlog items. Optionally filter by status: Open | InProgress | Done | \
             Cancelled."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for GetBacklogTool {
    async fn invoke(
        service: &OntoreServer,
        param: BacklogInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "get_backlog", project = ?param.project, status = ?param.status);
        let ctx = service.project_ctx(param.project.as_deref());
        let backlog_path =
            ontoref_ontology::layout::resolve_section(&ctx.root, "reflection/backlog.ncl");

        if !backlog_path.exists() {
            return Ok(serde_json::json!({ "items": [] }));
        }

        let (v, _) = ctx
            .cache
            .export(&backlog_path, ctx.import_path.as_deref())
            .await
            .map_err(|e| ToolError(e.to_string()))?;

        let items: Vec<serde_json::Value> = v
            .get("items")
            .and_then(|i| i.as_array())
            .map(|arr| {
                arr.iter()
                    .filter(|it| match &param.status {
                        Some(f) => it
                            .get("status")
                            .and_then(|s| s.as_str())
                            .map(|s| s.eq_ignore_ascii_case(f))
                            .unwrap_or(false),
                        None => true,
                    })
                    .cloned()
                    .collect()
            })
            .unwrap_or_default();

        Ok(serde_json::json!({ "items": items }))
    }
}

// ── Tool: get_node
// ──────────────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_get_node",
    description = "Full ontology node by id substring match.",
    category = "ontology",
    params = "id:string:required:Node id substring; project:string:optional:Project slug"
)]
struct GetNodeTool;

impl ToolBase for GetNodeTool {
    type Parameter = GetItemInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_get_node".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            concat!(
                "Get full details of an ontology node by id. ",
                "Use after `search` to retrieve complete node data — ",
                "axioms, tensions, practices, pole, and level.",
            )
            .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for GetNodeTool {
    async fn invoke(
        service: &OntoreServer,
        param: GetItemInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "get_node", id = %param.id, project = ?param.project);
        let ctx = service.project_ctx(param.project.as_deref());
        let core_path = ontoref_ontology::layout::resolve_section(&ctx.root, "ontology/core.ncl");

        let (json, _) = ctx
            .cache
            .export(&core_path, ctx.import_path.as_deref())
            .await
            .map_err(|e| ToolError(e.to_string()))?;

        let nodes = json
            .get("nodes")
            .and_then(|n| n.as_array())
            .ok_or_else(|| ToolError("core.ncl has no nodes array".to_string()))?;

        let id_lower = param.id.to_lowercase();
        for node in nodes {
            let node_id = node.get("id").and_then(|v| v.as_str()).unwrap_or("");
            let node_name = node.get("name").and_then(|v| v.as_str()).unwrap_or("");
            if node_id.to_lowercase().contains(&id_lower)
                || node_name.to_lowercase().contains(&id_lower)
            {
                return Ok(node.clone());
            }
        }

        Err(ToolError(format!("node '{}' not found", param.id)))
    }
}

// ── Tool: ontoref_get (unified dispatcher) ───────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_get",
    description = "Retrieve full details of any item by kind+id (from ontoref_search results).",
    category = "ontology",
    params = "id:string:required:Item id from ontoref_search; kind:string:required:node|adr|mode; \
              project:string:optional:Project slug"
)]
struct GetTool;

impl ToolBase for GetTool {
    type Parameter = GetInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_get".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            concat!(
                "Retrieve full details of any item by id and kind. ",
                "Use the `id` and `kind` fields returned by `ontoref_search`. ",
                "kind: `\"node\"` → ontology node, `\"adr\"` → Architecture Decision Record, ",
                "`\"mode\"` → reflection workflow.",
            )
            .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for GetTool {
    async fn invoke(
        service: &OntoreServer,
        param: GetInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "ontoref_get", kind = %param.kind, id = %param.id);
        let item = GetItemInput {
            id: param.id,
            project: param.project,
        };
        match param.kind.as_str() {
            "node" => GetNodeTool::invoke(service, item).await,
            "adr" => GetAdrTool::invoke(service, item).await,
            "mode" => GetModeTool::invoke(service, item).await,
            other => Err(ToolError(format!(
                "unknown kind '{other}'; expected node, adr, or mode"
            ))),
        }
    }
}

// ── Tool: ontoref_help
// ────────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_help",
    description = "List all available ontoref MCP tools with descriptions, parameters, and the \
                   current active project.",
    category = "discovery"
)]
struct HelpTool;

impl ToolBase for HelpTool {
    type Parameter = NoInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_help".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "List all available ontoref MCP tools with descriptions, parameters, and the current \
             active project."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for HelpTool {
    async fn invoke(
        service: &OntoreServer,
        _param: NoInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "ontoref_help");
        let current_project = service
            .state
            .mcp_current_project
            .read()
            .ok()
            .and_then(|g| g.clone());
        let available = service.available_projects();

        let tools: Vec<serde_json::Value> =
            catalog().iter().map(|t| render_tool_for_help(t)).collect();

        Ok(serde_json::json!({
            "current_project": current_project,
            "available_projects": available,
            "tip": "Run ontoref_set_project to avoid specifying 'project' on every call.",
            "tools": tools,
        }))
    }
}

// ── Tool: set_project ────────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_set_project",
    description = "Set the default project for this session.",
    category = "discovery",
    params = "project:string:required:Project slug from ontoref_list_projects"
)]
struct SetProjectTool;

impl ToolBase for SetProjectTool {
    type Parameter = SetProjectInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_set_project".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            concat!(
                "Set the current default project for this MCP session. ",
                "Once set, all tools that accept an optional `project` field will use it ",
                "automatically without needing to specify it on every call.",
            )
            .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for SetProjectTool {
    async fn invoke(
        service: &OntoreServer,
        param: SetProjectInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "set_project", project = %param.project);
        // Verify project exists when registry is available.
        #[cfg(feature = "ui")]
        if service.state.registry.get(&param.project).is_none() {
            let available = service.available_projects();
            return Err(ToolError(format!(
                "project '{}' not found; available: {}",
                param.project,
                available.join(", ")
            )));
        }
        {
            let mut guard = service
                .state
                .mcp_current_project
                .write()
                .map_err(|_| ToolError("lock poisoned".to_string()))?;
            *guard = Some(param.project.clone());
        }
        Ok(serde_json::json!({
            "ok":      true,
            "project": param.project,
            "message": format!("Current project set to '{}'", param.project),
        }))
    }
}

// ── Tool: project_status ─────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_status",
    description = "Full dashboard: actors, notifications, backlog stats, ADR/mode counts, \
                   manifest.",
    category = "discovery",
    params = "project:string:optional:Project slug"
)]
struct ProjectStatusTool;

impl ToolBase for ProjectStatusTool {
    type Parameter = ProjectParam;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_status".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            concat!(
                "Full dashboard snapshot: actors, notifications, backlog stats ",
                "(open/in-progress/done/cancelled), ADR count, mode count, and manifest.",
            )
            .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for ProjectStatusTool {
    async fn invoke(
        service: &OntoreServer,
        param: ProjectParam,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "project_status", project = ?param.project);
        let ctx = service.project_ctx(param.project.as_deref());

        // Actors
        let actor_list: Vec<serde_json::Value> = service
            .state
            .actors
            .list()
            .into_iter()
            .map(|(token, view)| {
                serde_json::json!({
                    "token":      token,
                    "actor_type": view.actor_type,
                    "pid":        view.pid,
                    "project":    view.project,
                })
            })
            .collect();

        // Notifications — recent count (all projects, last slice)
        let recent_notifications = service.state.notifications.all_recent().len();

        // Backlog stats
        let backlog_path =
            ontoref_ontology::layout::resolve_section(&ctx.root, "reflection/backlog.ncl");
        let backlog_stats = if backlog_path.exists() {
            match ctx
                .cache
                .export(&backlog_path, ctx.import_path.as_deref())
                .await
            {
                Ok((v, _)) => {
                    let items = v
                        .get("items")
                        .and_then(|i| i.as_array())
                        .cloned()
                        .unwrap_or_default();
                    let stats = backlog_item_stats(&items);
                    serde_json::json!({
                        "total":       items.len(),
                        "open":        stats.0,
                        "in_progress": stats.1,
                        "done":        stats.2,
                        "cancelled":   stats.3,
                        "critical":    stats.4,
                    })
                }
                Err(_) => serde_json::json!({ "error": "backlog export failed" }),
            }
        } else {
            serde_json::json!({ "total": 0, "open": 0, "in_progress": 0, "done": 0, "cancelled": 0, "critical": 0 })
        };

        // ADR count
        let adr_count =
            std::fs::read_dir(ontoref_ontology::layout::resolve_section(&ctx.root, "adrs"))
                .map(|rd| {
                    rd.flatten()
                        .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("ncl"))
                        .count()
                })
                .unwrap_or(0);

        // Mode count
        let mode_count = std::fs::read_dir(ontoref_ontology::layout::resolve_section(
            &ctx.root,
            "reflection/modes",
        ))
        .map(|rd| {
            rd.flatten()
                .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("ncl"))
                .count()
        })
        .unwrap_or(0);

        // Manifest
        let manifest_path =
            ontoref_ontology::layout::resolve_section(&ctx.root, "ontology/manifest.ncl");
        let manifest = if manifest_path.exists() {
            match ctx
                .cache
                .export(&manifest_path, ctx.import_path.as_deref())
                .await
            {
                Ok((v, _)) => v,
                Err(e) => {
                    warn!(error = %e, "project_status: manifest export failed");
                    serde_json::Value::Null
                }
            }
        } else {
            serde_json::Value::Null
        };

        // Extract structured registry topology from manifest so MCP consumers
        // don't need to navigate the raw NCL JSON themselves.
        let registry = {
            let rp = manifest.get("registry_provides");
            let participant = rp
                .and_then(|v| v.get("participant"))
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_owned();
            let registries = rp.and_then(|v| v.get("registries"));
            let default_id = registries
                .and_then(|v| v.get("default"))
                .and_then(|v| v.as_str())
                .unwrap_or("primary");
            let entry = registries
                .and_then(|v| v.get("registries"))
                .and_then(|v| v.as_array())
                .and_then(|arr| {
                    arr.iter()
                        .find(|e| e.get("id").and_then(|i| i.as_str()) == Some(default_id))
                })
                .cloned()
                .unwrap_or(serde_json::Value::Null);
            let namespaces = entry
                .get("namespaces")
                .and_then(|n| n.get("own"))
                .and_then(|v| v.as_array())
                .cloned()
                .unwrap_or_default();
            let endpoint = entry
                .get("endpoint")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_owned();
            if participant.is_empty() {
                serde_json::Value::Null
            } else {
                serde_json::json!({
                    "participant": participant,
                    "endpoint":    endpoint,
                    "namespaces":  namespaces,
                })
            }
        };

        Ok(serde_json::json!({
            "project":       ctx.root.file_name().and_then(|n| n.to_str()).unwrap_or("unknown"),
            "root":          ctx.root.display().to_string(),
            "actors":        { "count": actor_list.len(), "list": actor_list },
            "notifications": { "recent": recent_notifications },
            "backlog":       backlog_stats,
            "adrs":          adr_count,
            "modes":         mode_count,
            "registry":      registry,
            "manifest":      manifest,
        }))
    }
}

// ── Tool: backlog
// ─────────────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_backlog",
    description = "Mutate backlog: add a new item or update an item's status.",
    category = "knowledge",
    params = "operation:string:required:add|update_status; project:string:optional:Project slug; \
              id:string:optional:required for update_status; \
              status:string:optional:Open|InProgress|Done|Cancelled (required for update_status); \
              title:string:optional:required for add; \
              kind:string:optional:Feature|Bug|Chore|Research; \
              priority:string:optional:Critical|High|Medium|Low; detail:string:optional:Free-text \
              detail"
)]
struct BacklogOpTool;

impl ToolBase for BacklogOpTool {
    type Parameter = BacklogOpInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_backlog".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            concat!(
                "Manage the project backlog. ",
                "`operation: \"add\"` — create a new item (required: title, kind, priority; \
                 optional: detail). ",
                "`operation: \"update_status\"` — change item status (required: id, status). ",
                "Valid kinds: Feature | Bug | Chore | Research. ",
                "Valid priorities: Critical | High | Medium | Low. ",
                "Valid statuses: Open | InProgress | Done | Cancelled.",
            )
            .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for BacklogOpTool {
    async fn invoke(
        service: &OntoreServer,
        param: BacklogOpInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "backlog", operation = %param.operation, project = ?param.project);
        let ctx = service.project_ctx(param.project.as_deref());
        let backlog_path =
            ontoref_ontology::layout::resolve_section(&ctx.root, "reflection/backlog.ncl");

        if !backlog_path.exists() {
            return Err(ToolError(format!(
                "backlog.ncl not found at {}",
                backlog_path.display()
            )));
        }

        let today = today_iso();

        match param.operation.as_str() {
            "update_status" => {
                let id = param
                    .id
                    .as_deref()
                    .ok_or_else(|| ToolError("`id` is required for update_status".to_string()))?;
                let status = param.status.as_deref().ok_or_else(|| {
                    ToolError("`status` is required for update_status".to_string())
                })?;

                crate::ui::backlog_ncl::update_status(&backlog_path, id, status, &today)
                    .map_err(|e| ToolError(e.to_string()))?;

                // Invalidate cache so subsequent get_backlog reads the updated file.
                ctx.cache.invalidate_file(&backlog_path);

                Ok(serde_json::json!({
                    "ok":     true,
                    "id":     id,
                    "status": status,
                    "updated": today,
                }))
            }
            "add" => {
                let title = param
                    .title
                    .as_deref()
                    .ok_or_else(|| ToolError("`title` is required for add".to_string()))?;
                let kind = param.kind.as_deref().unwrap_or("Feature");
                let priority = param.priority.as_deref().unwrap_or("Medium");
                let detail = param.detail.as_deref().unwrap_or("");

                let new_id = crate::ui::backlog_ncl::add_item(
                    &backlog_path,
                    title,
                    kind,
                    priority,
                    detail,
                    &today,
                )
                .map_err(|e| ToolError(e.to_string()))?;

                ctx.cache.invalidate_file(&backlog_path);

                Ok(serde_json::json!({
                    "ok":       true,
                    "id":       new_id,
                    "title":    title,
                    "kind":     kind,
                    "priority": priority,
                    "status":   "Open",
                    "created":  today,
                }))
            }
            other => Err(ToolError(format!(
                "unknown operation '{}'; expected 'add' or 'update_status'",
                other
            ))),
        }
    }
}

/// Returns (open, in_progress, done, cancelled, critical) counts.
fn backlog_item_stats(items: &[serde_json::Value]) -> (usize, usize, usize, usize, usize) {
    let mut open = 0usize;
    let mut in_progress = 0usize;
    let mut done = 0usize;
    let mut cancelled = 0usize;
    let mut critical = 0usize;
    for item in items {
        match item.get("status").and_then(|s| s.as_str()) {
            Some("Open") => open += 1,
            Some("InProgress") => in_progress += 1,
            Some("Done") => done += 1,
            Some("Cancelled") => cancelled += 1,
            _ => {}
        }
        if item.get("priority").and_then(|p| p.as_str()) == Some("Critical") {
            critical += 1;
        }
    }
    (open, in_progress, done, cancelled, critical)
}

fn today_iso() -> String {
    use std::time::{SystemTime, UNIX_EPOCH};
    let days = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs()
        / 86400;
    // Hinnant's civil_from_days algorithm (proleptic Gregorian).
    let z = days + 719_468;
    let era = z / 146_097;
    let doe = z - era * 146_097;
    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
    let y0 = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = if m <= 2 { y0 + 1 } else { y0 };
    format!("{y:04}-{m:02}-{d:02}")
}

fn collect_constraints(
    arr: Option<&serde_json::Value>,
    adr_id: &str,
    adr_title: &str,
    out: &mut Vec<serde_json::Value>,
) {
    let Some(items) = arr.and_then(|v| v.as_array()) else {
        return;
    };
    for text in items {
        out.push(serde_json::json!({
            "adr":       adr_id,
            "adr_title": adr_title,
            "text":      text.as_str().unwrap_or(""),
        }));
    }
}

// ── Tool: get_constraints
// ───────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_constraints",
    description = "All hard and soft architectural constraints extracted from all ADRs.",
    category = "validation",
    params = "project:string:optional:Project slug"
)]
struct GetConstraintsTool;

impl ToolBase for GetConstraintsTool {
    type Parameter = ProjectParam;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_constraints".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "Extract all hard and soft architectural constraints from all ADRs in the project."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for GetConstraintsTool {
    async fn invoke(
        service: &OntoreServer,
        param: ProjectParam,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "get_constraints", project = ?param.project);
        let ctx = service.project_ctx(param.project.as_deref());
        let adrs_dir = ontoref_ontology::layout::resolve_section(&ctx.root, "adrs");

        let Ok(entries) = std::fs::read_dir(&adrs_dir) else {
            return Ok(serde_json::json!({ "hard": [], "soft": [] }));
        };

        let mut hard: Vec<serde_json::Value> = Vec::new();
        let mut soft: Vec<serde_json::Value> = Vec::new();

        for entry in entries.flatten() {
            let path = entry.path();
            if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
                continue;
            }
            let Ok((v, _)) = ctx.cache.export(&path, ctx.import_path.as_deref()).await else {
                continue;
            };
            let adr_id = v
                .get("id")
                .and_then(|i| i.as_str())
                .unwrap_or("")
                .to_string();
            let adr_title = v
                .get("title")
                .and_then(|t| t.as_str())
                .unwrap_or("")
                .to_string();
            let Some(c) = v.get("constraints").and_then(|c| c.as_object()) else {
                continue;
            };
            collect_constraints(c.get("hard"), &adr_id, &adr_title, &mut hard);
            collect_constraints(c.get("soft"), &adr_id, &adr_title, &mut soft);
        }

        Ok(serde_json::json!({ "hard": hard, "soft": soft }))
    }
}

// ── Tool: qa_list
// ────────────────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_qa_list",
    description = "List Q&A entries from reflection/qa.ncl. Optionally filter by question \
                   substring.",
    category = "knowledge",
    params = "project:string:optional:Project slug; filter:string:optional:Question substring \
              filter"
)]
struct QaListTool;

impl ToolBase for QaListTool {
    type Parameter = QaListInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_qa_list".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "List Q&A entries stored in reflection/qa.ncl. Optionally filter by question \
             substring."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for QaListTool {
    async fn invoke(
        service: &OntoreServer,
        param: QaListInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "qa_list", project = ?param.project);
        let ctx = service.project_ctx(param.project.as_deref());
        let qa_path = ontoref_ontology::layout::resolve_section(&ctx.root, "reflection/qa.ncl");

        if !qa_path.exists() {
            return Ok(serde_json::json!({ "entries": [], "count": 0 }));
        }

        let (json, _) = ctx
            .cache
            .export(&qa_path, ctx.import_path.as_deref())
            .await
            .map_err(|e| ToolError(e.to_string()))?;

        let mut entries: Vec<serde_json::Value> = json
            .get("entries")
            .and_then(|v| v.as_array())
            .cloned()
            .unwrap_or_default();

        if let Some(filter) = param.filter.as_deref() {
            let lc = filter.to_lowercase();
            entries.retain(|e| {
                e.get("question")
                    .and_then(|q| q.as_str())
                    .map(|q| q.to_lowercase().contains(&lc))
                    .unwrap_or(false)
            });
        }

        let count = entries.len();
        Ok(serde_json::json!({ "entries": entries, "count": count }))
    }
}

// ── Tool: qa_add
// ─────────────────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_qa_add",
    description = "Add a Q&A entry to reflection/qa.ncl (persisted to disk). Use to record \
                   architectural knowledge.",
    category = "knowledge",
    params = "question:string:required:Question text; answer:string:optional:Answer text; \
              actor:string:optional:default=agent; tags:array:optional:Tag strings; \
              related:array:optional:Related node or ADR ids; project:string:optional:Project slug"
)]
struct QaAddTool;

impl ToolBase for QaAddTool {
    type Parameter = QaAddInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_qa_add".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            concat!(
                "Add a Q&A entry to reflection/qa.ncl (persisted to disk, git-versioned). ",
                "Use this to record architectural questions and answers the AI discovers during \
                 analysis. ",
                "Required: question. Optional: answer, actor, tags (array), related (node/ADR \
                 ids).",
            )
            .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for QaAddTool {
    async fn invoke(
        service: &OntoreServer,
        param: QaAddInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "qa_add", project = ?param.project);
        let ctx = service.project_ctx(param.project.as_deref());
        let qa_path = ontoref_ontology::layout::resolve_section(&ctx.root, "reflection/qa.ncl");

        if !qa_path.exists() {
            return Err(ToolError(format!(
                "qa.ncl not found at {} — create reflection/qa.ncl first",
                qa_path.display()
            )));
        }

        let answer = param.answer.as_deref().unwrap_or("");
        let actor = param.actor.as_deref().unwrap_or("agent");
        let tags = param.tags.as_deref().unwrap_or(&[]);
        let related = param.related.as_deref().unwrap_or(&[]);
        let now = today_iso();

        let id = crate::ui::qa_ncl::add_entry(
            &qa_path,
            &param.question,
            answer,
            actor,
            &now,
            tags,
            related,
        )
        .map_err(|e| ToolError(e.to_string()))?;

        ctx.cache.invalidate_file(&qa_path);

        Ok(serde_json::json!({
            "ok":         true,
            "id":         id,
            "created_at": now,
            "question":   param.question,
            "answer":     answer,
        }))
    }
}

// ── Tool: action_list
// ────────────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_action_list",
    description = "List quick actions from .ontoref/config.ncl quick_actions array.",
    category = "knowledge",
    params = "project:string:optional:Project slug"
)]
struct ActionListTool;

impl ToolBase for ActionListTool {
    type Parameter = ActionListInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_action_list".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "List quick actions configured in .ontoref/config.ncl (`quick_actions` array). \
             Returns id, label, category, icon, actors, and mode for each action."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for ActionListTool {
    async fn invoke(
        service: &OntoreServer,
        param: ActionListInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "action_list", project = ?param.project);
        let ctx = service.project_ctx(param.project.as_deref());
        let config_path = ctx.root.join(".ontoref").join("config.ncl");

        if !config_path.exists() {
            return Ok(serde_json::json!({ "actions": [], "count": 0 }));
        }

        let (json, _) = ctx
            .cache
            .export(&config_path, ctx.import_path.as_deref())
            .await
            .map_err(|e| ToolError(e.to_string()))?;

        let actions = json
            .get("quick_actions")
            .and_then(|v| v.as_array())
            .cloned()
            .unwrap_or_default();

        let count = actions.len();
        Ok(serde_json::json!({ "actions": actions, "count": count }))
    }
}

// ── Tool: action_add
// ─────────────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_action_add",
    description = "Create a new reflection mode file in reflection/modes/<id>.ncl.",
    category = "knowledge",
    params = "id:string:required:Mode id slug; label:string:required:Display label; \
              trigger:string:required:Trigger description; steps:array:required:Step strings; \
              icon:string:optional:book-open|refresh|code|bolt; \
              category:string:optional:docs|sync|analysis|test; \
              actors:array:optional:developer|agent|ci; project:string:optional:Project slug"
)]
struct ActionAddTool;

impl ToolBase for ActionAddTool {
    type Parameter = ActionAddInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_action_add".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            concat!(
                "Create a new reflection mode file in reflection/modes/<id>.ncl. ",
                "The mode is immediately available as a runnable action via the CLI and UI. ",
                "Required: id (slug), label, trigger, steps (array of step descriptions). ",
                "Optional: icon, category, actors.",
            )
            .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for ActionAddTool {
    async fn invoke(
        service: &OntoreServer,
        param: ActionAddInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "action_add", id = %param.id, project = ?param.project);
        let ctx = service.project_ctx(param.project.as_deref());
        let modes_dir = ontoref_ontology::layout::resolve_section(&ctx.root, "reflection/modes");

        if !modes_dir.exists() {
            return Err(ToolError(format!(
                "reflection/modes/ directory not found at {}",
                modes_dir.display()
            )));
        }

        let mode_path = modes_dir.join(format!("{}.ncl", param.id));
        if mode_path.exists() {
            return Err(ToolError(format!(
                "mode {} already exists — choose a different id",
                param.id
            )));
        }

        let icon = param.icon.as_deref().unwrap_or("bolt");
        let category = param.category.as_deref().unwrap_or("analysis");
        let actors = param.actors.as_deref().unwrap_or(&[]);
        let actors_ncl = ncl_str_array(actors);

        let steps_ncl = param
            .steps
            .iter()
            .map(|s| format!("    \"{}\",\n", escape_mcp(s)))
            .collect::<String>();

        let content = format!(
            r#"{{
  id      = "{id}",
  label   = "{label}",
  trigger = "{trigger}",
  icon    = "{icon}",
  category = "{category}",
  actors  = {actors},
  steps   = [
{steps}  ],
}}
"#,
            id = escape_mcp(&param.id),
            label = escape_mcp(&param.label),
            trigger = escape_mcp(&param.trigger),
            icon = icon,
            category = category,
            actors = actors_ncl,
            steps = steps_ncl,
        );

        std::fs::write(&mode_path, content).map_err(|e| ToolError(e.to_string()))?;

        // Invalidate any cached export for the modes directory items.
        ctx.cache.invalidate_file(&mode_path);

        Ok(serde_json::json!({
            "ok":    true,
            "id":    param.id,
            "path":  mode_path.display().to_string(),
            "label": param.label,
        }))
    }
}

// ── Tool: validate_adrs
// ──────────────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_validate_adrs",
    description = "Run typed constraint checks for all ADRs. Returns pass/fail per constraint \
                   with detail.",
    category = "validation",
    params = "project:string:optional:Project slug"
)]
struct ValidateAdrsTool;

impl ToolBase for ValidateAdrsTool {
    type Parameter = ProjectParam;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_validate_adrs".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "Run all typed constraint checks from accepted ADRs and return a structured \
             compliance report with per-constraint pass/fail results."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for ValidateAdrsTool {
    async fn invoke(
        service: &OntoreServer,
        param: ProjectParam,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "validate_adrs", project = ?param.project);
        let ctx = service.project_ctx(param.project.as_deref());

        let output = tokio::process::Command::new("nu")
            .args([
                "--no-config-file",
                "-c",
                "use reflection/modules/validate.nu *; validate check-all --fmt json",
            ])
            .current_dir(&ctx.root)
            .output()
            .await
            .map_err(|e| ToolError(format!("spawn failed: {e}")))?;

        let stdout = String::from_utf8_lossy(&output.stdout);
        serde_json::from_str::<serde_json::Value>(stdout.trim()).map_err(|e| {
            let stderr = String::from_utf8_lossy(&output.stderr);
            ToolError(format!(
                "invalid JSON from validate: {e}\nstderr: {}",
                stderr.trim()
            ))
        })
    }
}

// ── Tool: impact
// ──────────────────────────────────────────────────────────────────

#[derive(Deserialize, JsonSchema, Default)]
struct ImpactInput {
    /// Ontology node id to trace impact for (e.g. "dag-formalized").
    node: String,
    /// Project slug. Omit to use the default project.
    project: Option<String>,
    /// Maximum edge hops to follow (default 2, max 5).
    depth: Option<u32>,
    /// When true, follow connections.ncl entries to external projects.
    include_external: Option<bool>,
}

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_impact",
    description = "BFS impact graph from a node: what else is affected if this node changes.",
    category = "ontology",
    params = "node_id:string:required:Source node id; depth:u32:optional:default=2; \
              include_external:bool:optional:Traverse cross-project edges; \
              project:string:optional:Project slug"
)]
struct ImpactTool;

impl ToolBase for ImpactTool {
    type Parameter = ImpactInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_impact".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "Trace the impact graph of an ontology node: which nodes depend on it and which it \
             depends on. Set include_external=true to follow cross-project connections declared \
             in connections.ncl."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for ImpactTool {
    async fn invoke(
        service: &OntoreServer,
        param: ImpactInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "impact", node = %param.node, project = ?param.project);

        let effective_slug = param
            .project
            .clone()
            .unwrap_or_else(|| service.state.registry.primary_slug().to_owned());

        let fed = crate::federation::FederatedQuery::new(Arc::clone(&service.state.registry));
        let depth = param.depth.unwrap_or(2).min(5);
        let include_external = param.include_external.unwrap_or(false);

        let impacts = fed
            .impact_graph(&effective_slug, &param.node, depth, include_external)
            .await;
        Ok(serde_json::json!({
            "slug":             effective_slug,
            "node":             param.node,
            "depth":            depth,
            "include_external": include_external,
            "impacts":          impacts,
        }))
    }
}

// ── Tool: validate_project
// ────────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_validate",
    description = "Run the full project validation suite: ADR constraints, content assets, \
                   connections, gate consistency.",
    category = "validation",
    params = "project:string:optional:Project slug"
)]
struct ValidateProjectTool;

impl ToolBase for ValidateProjectTool {
    type Parameter = ProjectParam;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_validate".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "Run comprehensive project validation: ADR typed constraints, content asset paths, \
             connection health, practice coverage, and gate/dimension consistency. Returns a \
             structured compliance report. Non-zero exit when any Hard constraint fails."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for ValidateProjectTool {
    async fn invoke(
        service: &OntoreServer,
        param: ProjectParam,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "validate_project", project = ?param.project);
        let ctx = service.project_ctx(param.project.as_deref());

        // Run the aggregate summary step directly — faster than spawning the full DAG
        // mode.
        let output = tokio::process::Command::new("nu")
            .args([
                "--no-config-file",
                "-c",
                "use reflection/modules/validate.nu *; validate check-all --fmt json",
            ])
            .current_dir(&ctx.root)
            .output()
            .await
            .map_err(|e| ToolError(format!("spawn failed: {e}")))?;

        let stdout = String::from_utf8_lossy(&output.stdout);
        let passed = output.status.success();
        let report =
            serde_json::from_str::<serde_json::Value>(stdout.trim()).unwrap_or_else(|_| {
                let stderr = String::from_utf8_lossy(&output.stderr);
                serde_json::json!({
                    "raw_stdout": stdout.trim(),
                    "stderr":     stderr.trim(),
                })
            });

        Ok(serde_json::json!({
            "passed":  passed,
            "report":  report,
        }))
    }
}

// ── Tool: guides
// ──────────────────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_guides",
    description = "Complete project guide: identity, axioms, practices, constraints, gate state, \
                   modes, actor policy, content assets, connections. Canonical entry point for \
                   cold-start context.",
    category = "discovery",
    params = "project:string:optional:Project slug; actor:string:optional:developer|agent|ci|admin"
)]
struct GuidesTool;

impl ToolBase for GuidesTool {
    type Parameter = GuidesInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_guides".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "Return a complete project guide: identity, axioms, practices, constraints, gate \
             state, available modes, actor-specific policy, language guides, content assets, and \
             connections. Single deterministic JSON response — the canonical entry point for any \
             actor arriving at a project cold."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for GuidesTool {
    async fn invoke(
        service: &OntoreServer,
        param: GuidesInput,
    ) -> Result<serde_json::Value, ToolError> {
        let actor = param.actor.as_deref().unwrap_or("agent");
        debug!(tool = "guides", project = ?param.project, actor);
        let ctx = service.project_ctx(param.project.as_deref());

        let nu_cmd = format!(
            "use reflection/modules/describe.nu *; describe guides --actor {} --fmt json",
            actor,
        );
        let output = tokio::process::Command::new("nu")
            .args(["--no-config-file", "-c", &nu_cmd])
            .current_dir(&ctx.root)
            .output()
            .await
            .map_err(|e| ToolError(format!("spawn failed: {e}")))?;

        let stdout = String::from_utf8_lossy(&output.stdout);
        serde_json::from_str::<serde_json::Value>(stdout.trim()).map_err(|e| {
            let stderr = String::from_utf8_lossy(&output.stderr);
            ToolError(format!(
                "invalid JSON from describe guides: {e}\nstderr: {}",
                stderr.trim()
            ))
        })
    }
}

// ── Tool: api_catalog
// ────────────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_api_catalog",
    description = "Annotated daemon API surface: all HTTP routes with method, path, auth, actors, \
                   params, tags.",
    category = "discovery",
    params = "actor:string:optional:developer|agent|ci|admin; tag:string:optional:Semantic tag \
              filter; auth:string:optional:none|viewer|admin"
)]
struct ApiCatalogTool;

impl ToolBase for ApiCatalogTool {
    type Parameter = ApiCatalogInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_api_catalog".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "Return the annotated daemon API surface: all HTTP routes with method, path, auth \
             level, allowed actors, parameters, and tags. Filterable by actor, tag, or auth. Use \
             to understand what endpoints are available and how to call them."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for ApiCatalogTool {
    async fn invoke(
        _service: &OntoreServer,
        param: ApiCatalogInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "api_catalog", actor = ?param.actor, tag = ?param.tag, auth = ?param.auth);
        let routes: Vec<serde_json::Value> = crate::api_catalog::catalog()
            .into_iter()
            .filter(|r| {
                let actor_ok = param.actor.as_deref().is_none_or(|a| r.actors.contains(&a));
                let tag_ok = param.tag.as_deref().is_none_or(|t| r.tags.contains(&t));
                let auth_ok = param.auth.as_deref().is_none_or(|a| r.auth == a);
                actor_ok && tag_ok && auth_ok
            })
            .map(|r| {
                let params: Vec<serde_json::Value> = r
                    .params
                    .iter()
                    .map(|p| {
                        serde_json::json!({
                            "name": p.name,
                            "type": p.kind,
                            "constraint": p.constraint,
                            "description": p.description,
                        })
                    })
                    .collect();
                serde_json::json!({
                    "method":      r.method,
                    "path":        r.path,
                    "description": r.description,
                    "auth":        r.auth,
                    "actors":      r.actors,
                    "params":      params,
                    "tags":        r.tags,
                    "feature":     r.feature,
                })
            })
            .collect();
        let total = routes.len();
        Ok(serde_json::json!({ "routes": routes, "total": total }))
    }
}

// ── Tool: file_versions
// ──────────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_file_versions",
    description = "Per-file reload counters for ontology files. Counter increments on each daemon \
                   reload of that file.",
    category = "discovery",
    params = "project:string:optional:Project slug"
)]
struct FileVersionsTool;

impl ToolBase for FileVersionsTool {
    type Parameter = FileVersionsInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_file_versions".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "Return per-file reload counters for the project's ontology files. Each counter \
             increments when the file changes on disk and the daemon reloads it. Use to detect \
             which ontology files have changed since the agent last read them."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for FileVersionsTool {
    async fn invoke(
        service: &OntoreServer,
        param: FileVersionsInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "file_versions", project = ?param.project);

        let current = service
            .state
            .mcp_current_project
            .read()
            .ok()
            .and_then(|g| g.clone());
        let effective = param.project.or(current);

        #[cfg(feature = "ui")]
        if let Some(slug) = effective.as_deref() {
            if let Some(ctx) = service.state.registry.get(slug) {
                let files: std::collections::BTreeMap<String, u64> = ctx
                    .file_versions
                    .iter()
                    .map(|r| {
                        let name = r
                            .key()
                            .file_name()
                            .unwrap_or_default()
                            .to_string_lossy()
                            .into_owned();
                        (name, *r.value())
                    })
                    .collect();
                return Ok(serde_json::json!({ "project": slug, "files": files }));
            }
        }
        let _ = effective;

        let primary = service.state.registry.primary();
        let slug = service.state.registry.primary_slug().to_owned();
        let files: std::collections::BTreeMap<String, u64> = primary
            .file_versions
            .iter()
            .map(|r| {
                let name = r
                    .key()
                    .file_name()
                    .unwrap_or_default()
                    .to_string_lossy()
                    .into_owned();
                (name, *r.value())
            })
            .collect();
        Ok(serde_json::json!({ "project": slug, "files": files }))
    }
}

fn ncl_str_array(items: &[String]) -> String {
    if items.is_empty() {
        return "[]".to_string();
    }
    let inner: Vec<String> = items
        .iter()
        .map(|s| format!("\"{}\"", escape_mcp(s)))
        .collect();
    format!("[{}]", inner.join(", "))
}

fn escape_mcp(s: &str) -> String {
    s.replace('\\', "\\\\").replace('"', "\\\"")
}

// ── ServerHandler
// ───────────────────────────────────────────────────────────────

#[tool_handler]
impl ServerHandler for OntoreServer {
    fn get_info(&self) -> ServerInfo {
        ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
            .with_protocol_version(ProtocolVersion::LATEST)
            .with_instructions(concat!(
                "Ontoref semantic knowledge graph. All tools are prefixed `ontoref_`. ",
                "Start with `ontoref_help` to see all tools and the current active project. ",
                "Use `ontoref_set_project` once to avoid repeating `project` on every call. ",
                "Use `ontoref_guides` for full project context on cold start (axioms, practices, \
                 gate, actor policy). ",
                "Use `ontoref_search` for queries; then `ontoref_get` with the returned kind+id \
                 for details. ",
                "Use `ontoref_status` for a project dashboard. ",
                "Use `ontoref_api_catalog` to discover daemon endpoints by actor or tag. ",
                "Use `ontoref_file_versions` to detect which ontology files changed since last \
                 read. ",
                "Use `ontoref_validate_adrs` or `ontoref_validate` to run architectural \
                 constraint checks. ",
                "Use `ontoref_backlog` to add items or update status.",
            ))
    }
}

// ── Entry points
// ────────────────────────────────────────────────────────────────

// ── Tool: bookmark_list
// ─────────────────────────────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_bookmark_list",
    description = "List saved search bookmarks for the project.",
    category = "knowledge",
    params = "project:string:optional:Project slug; filter:string:optional:Substring filter"
)]
struct BookmarkListTool;

impl ToolBase for BookmarkListTool {
    type Parameter = BookmarkListInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_bookmark_list".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "List search bookmarks stored in reflection/search_bookmarks.ncl. Optionally filter \
             by node_id or title substring."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for BookmarkListTool {
    async fn invoke(
        service: &OntoreServer,
        param: BookmarkListInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "bookmark_list", project = ?param.project);
        let ctx = service.project_ctx(param.project.as_deref());
        let bm_path =
            ontoref_ontology::layout::resolve_section(&ctx.root, "reflection/search_bookmarks.ncl");

        if !bm_path.exists() {
            return Ok(serde_json::json!({ "entries": [], "count": 0 }));
        }

        let (json, _) = ctx
            .cache
            .export(&bm_path, ctx.import_path.as_deref())
            .await
            .map_err(|e| ToolError(e.to_string()))?;

        let mut entries: Vec<serde_json::Value> = json
            .get("entries")
            .and_then(|v| v.as_array())
            .cloned()
            .unwrap_or_default();

        if let Some(filter) = param.filter.as_deref() {
            let lc = filter.to_lowercase();
            entries.retain(|e| {
                let id_match = e
                    .get("node_id")
                    .and_then(|v| v.as_str())
                    .map(|s| s.to_lowercase().contains(&lc))
                    .unwrap_or(false);
                let title_match = e
                    .get("title")
                    .and_then(|v| v.as_str())
                    .map(|s| s.to_lowercase().contains(&lc))
                    .unwrap_or(false);
                id_match || title_match
            });
        }

        let count = entries.len();
        Ok(serde_json::json!({ "entries": entries, "count": count }))
    }
}

// ── Tool: bookmark_add
// ────────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_bookmark_add",
    description = "Save a search bookmark (node_id + title + optional tags).",
    category = "knowledge",
    params = "node_id:string:required:Node id; title:string:required:Bookmark title; \
              kind:string:optional:node|adr|mode; tags:array:optional:Tag strings; \
              project:string:optional:Project slug"
)]
struct BookmarkAddTool;

impl ToolBase for BookmarkAddTool {
    type Parameter = BookmarkAddInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_bookmark_add".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            concat!(
                "Save a search result as a bookmark in reflection/search_bookmarks.ncl (persisted \
                 to disk, git-versioned). ",
                "Use this when the user stars/bookmarks a search result in the CLI or UI. ",
                "Required: node_id, title. Optional: kind, level, term, actor, tags.",
            )
            .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for BookmarkAddTool {
    async fn invoke(
        service: &OntoreServer,
        param: BookmarkAddInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "bookmark_add", project = ?param.project, node_id = %param.node_id);
        let ctx = service.project_ctx(param.project.as_deref());
        let bm_path =
            ontoref_ontology::layout::resolve_section(&ctx.root, "reflection/search_bookmarks.ncl");

        if !bm_path.exists() {
            return Err(ToolError(format!(
                "search_bookmarks.ncl not found at {} — run ontoref setup first",
                bm_path.display()
            )));
        }

        let kind = param.kind.as_deref().unwrap_or("node");
        let level = param.level.as_deref().unwrap_or("");
        let term = param.term.as_deref().unwrap_or("");
        let actor = param.actor.as_deref().unwrap_or("agent");
        let tags = param.tags.as_deref().unwrap_or(&[]);
        let now = today_iso();

        let id = crate::ui::search_bookmarks_ncl::add_entry(
            &bm_path,
            crate::ui::search_bookmarks_ncl::NewBookmark {
                node_id: &param.node_id,
                kind,
                title: &param.title,
                level,
                term,
                actor,
                created_at: &now,
                tags,
            },
        )
        .map_err(|e| ToolError(e.to_string()))?;

        ctx.cache.invalidate_file(&bm_path);

        Ok(serde_json::json!({
            "ok":         true,
            "id":         id,
            "created_at": now,
            "node_id":    param.node_id,
            "title":      param.title,
        }))
    }
}

// ── Tool: project_config (read full export or single section)
// ─────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_project_config",
    description = "Read a project's config surface. Returns the full NCL export (merged with \
                   active overrides) for all sections, or a single section when section is given.",
    category = "config",
    params = "project:string:optional:Project slug; section:string:optional:Section id"
)]
struct ProjectConfigTool;

impl ToolBase for ProjectConfigTool {
    type Parameter = ConfigReadInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_project_config".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "Read a project's config surface. Returns the full NCL export (merged with any active \
             overrides) for all sections, or a single section when `section` is given."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for ProjectConfigTool {
    async fn invoke(
        service: &OntoreServer,
        param: ConfigReadInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "project_config", project = ?param.project, section = ?param.section);
        let ctx = service.project_ctx(param.project.as_deref());

        let surface = service
            .state
            .registry
            .get(
                param
                    .project
                    .as_deref()
                    .unwrap_or(ctx.root.file_name().and_then(|n| n.to_str()).unwrap_or("")),
            )
            .and_then(|c| c.config_surface.clone());

        let Some(surface) = surface else {
            return Err(ToolError(
                "project has no config_surface in manifest.ncl".into(),
            ));
        };

        let entry = surface.entry_point_path(&ctx.root);
        let (full, _) = ctx
            .cache
            .export(&entry, ctx.import_path.as_deref())
            .await
            .map_err(|e| ToolError(e.to_string()))?;

        if let Some(section_id) = &param.section {
            Ok(full
                .get(section_id)
                .cloned()
                .unwrap_or(serde_json::Value::Null))
        } else {
            Ok(full)
        }
    }
}

// ── Tool: config_coherence
// ──────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_config_coherence",
    description = "Run the multi-consumer coherence check for a project's config surface. Reports \
                   unclaimed NCL fields and consumer fields missing from the NCL export.",
    category = "config",
    params = "project:string:optional:Project slug; section:string:optional:Section id"
)]
struct ConfigCoherenceTool;

impl ToolBase for ConfigCoherenceTool {
    type Parameter = ConfigReadInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_config_coherence".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "Run the multi-consumer coherence check for a project's config surface. Reports \
             unclaimed NCL fields (present in export but claimed by no consumer) and consumer \
             fields missing from the NCL export. Supply `section` to check one section only."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for ConfigCoherenceTool {
    async fn invoke(
        service: &OntoreServer,
        param: ConfigReadInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "config_coherence", project = ?param.project, section = ?param.section);
        let ctx = service.project_ctx(param.project.as_deref());
        let slug = param
            .project
            .as_deref()
            .or_else(|| ctx.root.file_name().and_then(|n| n.to_str()))
            .unwrap_or("default");

        let surface = service
            .state
            .registry
            .get(slug)
            .and_then(|c| c.config_surface.clone());

        let Some(surface) = surface else {
            return Err(ToolError(
                "project has no config_surface in manifest.ncl".into(),
            ));
        };

        let report = crate::config_coherence::check_project(
            slug,
            &surface,
            &ctx.root,
            &ctx.cache,
            ctx.import_path.as_deref(),
            param.section.as_deref(),
        )
        .await;

        serde_json::to_value(&report).map_err(ToolError::from)
    }
}

// ── Tool: config_quickref
// ───────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_config_quickref",
    description = "Generate living config documentation for a project. Combines current NCL \
                   values, manifest rationales, per-section meta records, override history, and \
                   coherence status.",
    category = "config",
    params = "project:string:optional:Project slug; section:string:optional:Section id"
)]
struct ConfigQuickrefTool;

impl ToolBase for ConfigQuickrefTool {
    type Parameter = ConfigReadInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_config_quickref".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "Generate living config documentation for a project. Combines current NCL values, \
             manifest rationales, per-section _meta_ records, override history, and coherence \
             status into a single JSON document. Use `section` to scope to one section."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for ConfigQuickrefTool {
    async fn invoke(
        service: &OntoreServer,
        param: ConfigReadInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "config_quickref", project = ?param.project, section = ?param.section);
        let ctx = service.project_ctx(param.project.as_deref());
        let slug = param
            .project
            .as_deref()
            .or_else(|| ctx.root.file_name().and_then(|n| n.to_str()))
            .unwrap_or("default");

        let surface = service
            .state
            .registry
            .get(slug)
            .and_then(|c| c.config_surface.clone());

        let Some(surface) = surface else {
            return Err(ToolError(
                "project has no config_surface in manifest.ncl".into(),
            ));
        };

        Ok(crate::config_coherence::build_quickref(
            slug,
            &surface,
            &ctx.root,
            &ctx.cache,
            ctx.import_path.as_deref(),
            param.section.as_deref(),
        )
        .await)
    }
}

// ── Tool: config_update (override layer mutation)
// ────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_config_update",
    description = "Mutate a config section via the override layer. Generates a \
                   {section}.overrides.ncl file merged after the original NCL. dry_run defaults \
                   to true.",
    category = "config",
    params = "project:string:required:Project slug; section:string:required:Section id; \
              values:json:required:JSON object with field updates; \
              dry_run:bool:optional:default=true; reason:string:optional:Audit reason"
)]
struct ConfigUpdateTool;

impl ToolBase for ConfigUpdateTool {
    type Parameter = ConfigUpdateInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_config_update".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "Mutate a config section via the override layer. Generates a \
             `{section}.overrides.ncl` file that is merged after the original NCL, preserving \
             comments and contracts. `dry_run` defaults to true — returns the proposed diff \
             without writing. Set `dry_run: false` to apply. Always include a `reason` for the \
             audit trail."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for ConfigUpdateTool {
    async fn invoke(
        service: &OntoreServer,
        param: ConfigUpdateInput,
    ) -> Result<serde_json::Value, ToolError> {
        let dry_run = param.dry_run.unwrap_or(true);
        debug!(
            tool = "config_update",
            project = ?param.project,
            section = %param.section,
            dry_run
        );
        let ctx = service.project_ctx(param.project.as_deref());
        let slug = param
            .project
            .as_deref()
            .or_else(|| ctx.root.file_name().and_then(|n| n.to_str()))
            .unwrap_or("default");

        let surface = service
            .state
            .registry
            .get(slug)
            .and_then(|c| c.config_surface.clone());

        let Some(surface) = surface else {
            return Err(ToolError(
                "project has no config_surface in manifest.ncl".into(),
            ));
        };

        let section_meta = surface
            .section(&param.section)
            .ok_or_else(|| ToolError(format!("section '{}' not in config_surface", param.section)))?
            .clone();

        if !section_meta.mutable {
            return Err(ToolError(format!(
                "section '{}' is marked immutable",
                param.section
            )));
        }

        let req = crate::api::ConfigUpdateRequest {
            values: param.values,
            dry_run,
            reason: param.reason.unwrap_or_default(),
        };

        if dry_run {
            Ok(crate::api::generate_override_diff_value(
                &surface,
                &param.section,
                &req,
                &ctx.root,
            ))
        } else {
            crate::api::apply_config_override(
                &surface,
                &param.section,
                &req,
                &ctx.root,
                &ctx.cache,
                ctx.import_path.as_deref(),
            )
            .await
            .map_err(|e| ToolError(e.to_string()))
        }
    }
}

/// Run the MCP server over stdin/stdout — for use as a `command`-mode MCP
/// server in Claude Desktop, Cursor, or any stdio-compatible AI client.
pub async fn serve_stdio(state: AppState) -> anyhow::Result<()> {
    let server = OntoreServer::new(state);
    let ct = server
        .serve(rmcp::transport::stdio())
        .await
        .map_err(|e| anyhow::anyhow!("MCP serve error: {e}"))?;
    ct.waiting()
        .await
        .map_err(|e| anyhow::anyhow!("MCP wait error: {e}"))?;
    Ok(())
}

// ── Tool: vault_status (ADR-017 / ADR-019) ──────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_vault_status",
    description = "Read-only credential vault state: vault_id, recipient count, recipient groups \
                   + path rules, last logged operation, master_key resolution. No decryption.",
    category = "credentials",
    params = "project:string:optional:Project slug (defaults to current project)"
)]
struct VaultStatusTool;

impl ToolBase for VaultStatusTool {
    type Parameter = ProjectParam;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_vault_status".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "Structured credential vault state for the active project (ADR-017 + ADR-019). \
             Returns: vault_id, vault_dir, backend, registry_endpoint, master_key (path or \
             unresolved-error), access_sops_present (bool), recipient_count, recipient_groups \
             (group→key list), recipient_rules (path→groups), actor_key_bindings, last_operation \
             from logs/access.jsonl. NEVER decrypts credentials."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for VaultStatusTool {
    async fn invoke(
        service: &OntoreServer,
        param: ProjectParam,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "vault_status", project = ?param.project);
        let ctx = service.project_ctx(param.project.as_deref());

        let project_ncl = ctx.root.join(".ontoref").join("project.ncl");
        if !project_ncl.exists() {
            return Ok(serde_json::json!({
                "ok": false,
                "reason": format!("no .ontoref/project.ncl at {}", ctx.root.display()),
            }));
        }

        // Evaluate project.ncl via the cache (consistent with other tools).
        let project_json: serde_json::Value = ctx
            .cache
            .export(&project_ncl, ctx.import_path.as_deref())
            .await
            .map(|(v, _)| v)
            .map_err(|e| ToolError(format!("project.ncl evaluation failed: {e}")))?;

        let sops = match project_json.get("sops") {
            Some(s) if !s.is_null() => s,
            _ => {
                return Ok(serde_json::json!({
                    "ok": false,
                    "reason": "project.ncl has no sops block — vault not configured",
                }));
            }
        };
        let enabled = sops
            .get("enabled")
            .and_then(|v| v.as_bool())
            .unwrap_or(false);
        if !enabled {
            return Ok(serde_json::json!({
                "ok": false,
                "reason": "sops.enabled = false in project.ncl",
            }));
        }

        let slug = project_json
            .get("slug")
            .and_then(|v| v.as_str())
            .unwrap_or("");
        let vault_id = sops
            .get("vault_id")
            .and_then(|v| v.as_str())
            .filter(|s| !s.is_empty())
            .unwrap_or(slug)
            .to_string();
        let vault_backend = sops
            .get("vault_backend")
            .and_then(|v| v.as_str())
            .unwrap_or("restic")
            .to_string();
        let registry_endpoint = sops
            .get("registry_endpoint")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        let actor_key_bindings = sops
            .get("actor_key_bindings")
            .cloned()
            .unwrap_or(serde_json::json!({}));
        let recipient_groups = sops
            .get("recipient_groups")
            .cloned()
            .unwrap_or(serde_json::json!({}));
        let recipient_rules = sops
            .get("recipient_rules")
            .cloned()
            .unwrap_or(serde_json::json!([]));

        // master_key path: project override → global config.
        let master_key = sops
            .get("master_key_path")
            .and_then(|v| v.as_str())
            .filter(|s| !s.is_empty())
            .map(String::from)
            .or_else(|| {
                let home = std::env::var_os("HOME").map(std::path::PathBuf::from)?;
                let global = home.join(".config").join("ontoref").join("config.ncl");
                if !global.exists() {
                    return None;
                }
                let out = std::process::Command::new("nickel")
                    .arg("export")
                    .arg(&global)
                    .output()
                    .ok()?;
                if !out.status.success() {
                    return None;
                }
                let v: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
                v.get("vault")
                    .and_then(|x| x.get("master_key_path"))
                    .and_then(|s| s.as_str())
                    .filter(|s| !s.is_empty())
                    .map(String::from)
            });

        let home = std::env::var_os("HOME")
            .map(std::path::PathBuf::from)
            .ok_or_else(|| ToolError("HOME not resolvable".into()))?;
        let vault_dir = home
            .join(".config")
            .join("ontoref")
            .join("vaults")
            .join(&vault_id);
        let access_sops = vault_dir.join("access.sops.yaml");
        let logs_jsonl = vault_dir.join("logs").join("access.jsonl");

        // Recipient count from access.sops.yaml's plaintext sops.age metadata.
        // The file format places one '- recipient: age1...' entry per recipient
        // in the sops metadata block; counting matching lines avoids needing a
        // YAML parser dependency in the daemon.
        let recipient_count: u64 = if access_sops.exists() {
            std::fs::read_to_string(&access_sops)
                .map(|s| {
                    s.lines()
                        .filter(|l| l.trim_start().starts_with("- recipient: age1"))
                        .count() as u64
                })
                .unwrap_or(0)
        } else {
            0
        };

        // Last operation from access.jsonl.
        let last_op = if logs_jsonl.exists() {
            std::fs::read_to_string(&logs_jsonl)
                .ok()
                .and_then(|s| s.lines().last().map(String::from))
                .and_then(|line| serde_json::from_str::<serde_json::Value>(&line).ok())
        } else {
            None
        };

        Ok(serde_json::json!({
            "ok":                  true,
            "vault_id":            vault_id,
            "vault_dir":           vault_dir.display().to_string(),
            "backend":             vault_backend,
            "registry_endpoint":   registry_endpoint,
            "master_key":          master_key,
            "access_sops_present": access_sops.exists(),
            "recipient_count":     recipient_count,
            "recipient_groups":    recipient_groups,
            "recipient_rules":     recipient_rules,
            "actor_key_bindings":  actor_key_bindings,
            "last_operation":      last_op,
        }))
    }
}

// ── Tool: positioning_get (ADR-035) ──────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_positioning_get",
    description = "Full positioning layer (ADR-035): audiences, value-props, campaigns. Returns \
                   empty layer if the project has not adopted positioning (.ontoref/positioning/ \
                   missing).",
    category = "discovery",
    params = "project:string:optional:Project slug"
)]
struct PositioningGetTool;

impl ToolBase for PositioningGetTool {
    type Parameter = ProjectParam;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_positioning_get".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "Full positioning layer (ADR-035). Returns { audiences, value_props, campaigns, \
             counts }. The layer is opt-in per ADR-029 — projects without `.ontoref/positioning/` \
             receive an empty layer (counts all zero), never an error."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for PositioningGetTool {
    async fn invoke(
        service: &OntoreServer,
        param: ProjectParam,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "positioning_get", project = ?param.project);
        let ctx = service.project_ctx(param.project.as_deref());
        let dir = ctx.root.join(".ontoref").join("positioning");
        let p = ontoref_ontology::ontology::Positioning::load(&dir)
            .map_err(|e| ToolError(format!("positioning load failed: {e:#}")))?;
        let live_value_props = p
            .value_props()
            .iter()
            .filter(|v| matches!(v.status, ontoref_ontology::types::PositioningStatus::Live))
            .count();
        Ok(serde_json::json!({
            "audiences":    p.audiences(),
            "value_props":  p.value_props(),
            "campaigns":    p.campaigns(),
            "counts": {
                "audiences":        p.audiences().len(),
                "value_props":      p.value_props().len(),
                "campaigns":        p.campaigns().len(),
                "live_value_props": live_value_props,
            },
        }))
    }
}

// ── Tool: positioning_get_value_prop ────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_positioning_get_value_prop",
    description = "Single value-prop by id, with its targeted audiences expanded inline. Returns \
                   the claim, evidence_adrs, narrative_path, launch_criteria, and the resolved \
                   Audience records for every id in audience_ids.",
    category = "discovery",
    params = "id:string:required:Value-prop id (e.g. vp-001-structure-that-remembers); \
              project:string:optional:Project slug"
)]
struct PositioningGetValuePropTool;

impl ToolBase for PositioningGetValuePropTool {
    type Parameter = GetItemInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_positioning_get_value_prop".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "Single value-prop by id with audiences expanded inline. Saves a second tool call: an \
             agent reasoning about an audience for a claim gets the resolved Audience records \
             alongside the value-prop in one response."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for PositioningGetValuePropTool {
    async fn invoke(
        service: &OntoreServer,
        param: GetItemInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "positioning_get_value_prop", id = %param.id, project = ?param.project);
        let ctx = service.project_ctx(param.project.as_deref());
        let dir = ctx.root.join(".ontoref").join("positioning");
        let p = ontoref_ontology::ontology::Positioning::load(&dir)
            .map_err(|e| ToolError(format!("positioning load failed: {e:#}")))?;
        let vp = p
            .value_prop_by_id(&param.id)
            .ok_or_else(|| ToolError(format!("value-prop '{}' not found", param.id)))?;
        let audiences: Vec<_> = vp
            .audience_ids
            .iter()
            .filter_map(|aid| p.audience_by_id(aid))
            .collect();
        Ok(serde_json::json!({
            "value_prop": vp,
            "audiences":  audiences,
        }))
    }
}

// ── Tool: positioning_audit ─────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_positioning_audit",
    description = "Cross-reference every value-prop's evidence_adrs against the ADR repository. \
                   Reports drift kinds (Missing | Superseded | Deprecated | Proposed) — only \
                   Accepted ADRs are clean backing for Live claims. Drift surfaces marketing \
                   claims whose architectural backing has shifted.",
    category = "validation",
    params = "project:string:optional:Project slug"
)]
struct PositioningAuditTool;

impl ToolBase for PositioningAuditTool {
    type Parameter = ProjectParam;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_positioning_audit".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "Cross-reference evidence_adrs against ADR status across all value-props. Drift \
             kinds: Missing (cited file absent), Superseded (with optional by= replacement), \
             Deprecated, Proposed (claim cites uncommitted decision). Returns { \
             checked_value_props, drift }."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for PositioningAuditTool {
    async fn invoke(
        service: &OntoreServer,
        param: ProjectParam,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "positioning_audit", project = ?param.project);
        let ctx = service.project_ctx(param.project.as_deref());
        let dir = ctx.root.join(".ontoref").join("positioning");
        let adrs_dir = ontoref_ontology::layout::resolve_section(&ctx.root, "adrs");
        let p = ontoref_ontology::ontology::Positioning::load(&dir)
            .map_err(|e| ToolError(format!("positioning load failed: {e:#}")))?;
        let report = crate::positioning::audit_positioning(&p, &adrs_dir);
        serde_json::to_value(report).map_err(ToolError::from)
    }
}

// ── Tool: domain_list ────────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_domain_list",
    description = "List the domains a project implements (consumer → domain direction).",
    category = "domain",
    params = "project:string:optional:Project slug"
)]
struct DomainListTool;

impl ToolBase for DomainListTool {
    type Parameter = ProjectParam;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_domain_list".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "Return all domains the project implements as { id, kind, path }[]. kind: config_flag \
             (domain_X=true in config.ncl), domain_origin (manifest.ncl), or impl_dir \
             (.{domain}.ontoref/ directory). Use to navigate from a consumer project to its \
             domain source."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for DomainListTool {
    async fn invoke(
        service: &OntoreServer,
        param: ProjectParam,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "domain_list", project = ?param.project);
        let pctx = param
            .project
            .as_deref()
            .and_then(|s| service.state.registry.get(s))
            .unwrap_or_else(|| service.state.registry.primary());
        let decls = crate::domain::project_domains(&pctx.root);
        Ok(serde_json::json!({ "slug": pctx.slug, "domains": decls }))
    }
}

// ── Tool: domain_consumers ───────────────────────────────────────────────────

#[derive(Deserialize, JsonSchema, Default)]
struct DomainIdInput {
    /// Domain id (e.g. "rustelo", "provisioning", "librosys").
    domain_id: String,
}

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_domain_consumers",
    description = "List all registered projects that implement a given domain (domain → consumers \
                   direction).",
    category = "domain",
    params = "domain_id:string:required:Domain id (e.g. rustelo, provisioning)"
)]
struct DomainConsumersTool;

impl ToolBase for DomainConsumersTool {
    type Parameter = DomainIdInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_domain_consumers".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "Return all registered projects implementing the given domain id. Result: { \
             domain_id, consumers: [{ slug, root, kind }] }. Use to navigate from a domain \
             project to all its consumer implementations."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for DomainConsumersTool {
    async fn invoke(
        service: &OntoreServer,
        param: DomainIdInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "domain_consumers", id = %param.domain_id);
        let consumers = crate::domain::consumers_of(&service.state.registry, &param.domain_id);
        Ok(serde_json::json!({ "domain_id": param.domain_id, "consumers": consumers }))
    }
}

// ── Tool: domain_show ────────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_domain_show",
    description = "Domain metadata from the ontoref registry plus all its consumer projects.",
    category = "domain",
    params = "domain_id:string:required:Domain id"
)]
struct DomainShowTool;

impl ToolBase for DomainShowTool {
    type Parameter = DomainIdInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_domain_show".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "Return domain metadata (name, description, schema_cmd) and all registered consumer \
             projects. Combines the domain definition with the live consumer graph."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for DomainShowTool {
    async fn invoke(
        service: &OntoreServer,
        param: DomainIdInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "domain_show", id = %param.domain_id);
        let ontoref_root = std::env::var("ONTOREF_ROOT")
            .map(std::path::PathBuf::from)
            .unwrap_or_else(|_| service.state.project_root.clone());
        let meta = crate::domain::domain_meta(&ontoref_root, &param.domain_id);
        let consumers = crate::domain::consumers_of(&service.state.registry, &param.domain_id);
        Ok(serde_json::json!({ "meta": meta, "consumers": consumers }))
    }
}

// ── Tool: config_view ────────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_config_view",
    description = "Return the runtime config (config.ncl) and project beacon (card.ncl) combined.",
    category = "describe",
    params = "project:string:optional:Project slug"
)]
struct ConfigViewTool;

impl ToolBase for ConfigViewTool {
    type Parameter = ProjectParam;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_config_view".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "Export the project's runtime config (config.ncl) and its self-definition beacon \
             (card.ncl) in a single call. Returns { config, card } — both are typed NCL exports. \
             Use to understand what tier the project is on, which mode rules apply, NATS config, \
             and the project's canonical identity."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for ConfigViewTool {
    async fn invoke(
        service: &OntoreServer,
        param: ProjectParam,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "config_view", project = ?param.project);
        let ctx = service.project_ctx(param.project.as_deref());

        let export_opt = |path: std::path::PathBuf| {
            let cache = Arc::clone(&ctx.cache);
            let ip = ctx.import_path.clone();
            async move {
                if !path.exists() {
                    return serde_json::Value::Null;
                }
                cache
                    .export(&path, ip.as_deref())
                    .await
                    .map(|(v, _)| v)
                    .unwrap_or(serde_json::Value::Null)
            }
        };

        let config = export_opt(ctx.root.join(".ontoref").join("config.ncl")).await;
        let card = export_opt(ctx.root.join(".ontoref").join("card.ncl")).await;
        Ok(serde_json::json!({ "config": config, "card": card }))
    }
}

// ── Tool: tier_status ────────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_tier_status",
    description = "Return the current tier and capability matrix (available/locked) for a project.",
    category = "describe",
    params = "project:string:optional:Project slug"
)]
struct TierStatusMcpTool;

impl ToolBase for TierStatusMcpTool {
    type Parameter = ProjectParam;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_tier_status".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "Return the project's declared tier (Tier0/Tier1/Tier2), phase, and the full \
             capability matrix split into { available, locked }. Each capability entry has id, \
             label, min_tier, description. Use to understand what the project can and cannot do \
             at its current adoption level."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for TierStatusMcpTool {
    async fn invoke(
        service: &OntoreServer,
        param: ProjectParam,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "tier_status", project = ?param.project);
        let pctx = param
            .project
            .as_deref()
            .and_then(|s| service.state.registry.get(s))
            .unwrap_or_else(|| service.state.registry.primary());
        let ops = pctx.ops();
        let view = ontoref_ops::capabilities::capability_view(ops.tier);
        Ok(serde_json::json!({
            "slug":      pctx.slug,
            "tier":      ops.tier,
            "phase":     ops.phase,
            "available": view.available,
            "locked":    view.locked,
        }))
    }
}

// ── Tool: tier_next ──────────────────────────────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_tier_next",
    description = "Show what the next tier adds and what adopting it requires.",
    category = "describe",
    params = "project:string:optional:Project slug"
)]
struct TierNextMcpTool;

impl ToolBase for TierNextMcpTool {
    type Parameter = ProjectParam;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_tier_next".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some(
            "Forward projection from the current tier. Returns { from, to, adds[], narrative }. \
             `adds` lists the capabilities unlocked at the next tier; `narrative` is the human \
             rationale. When the project is already at Tier2, `to` is null and `adds` is empty."
                .into(),
        )
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for TierNextMcpTool {
    async fn invoke(
        service: &OntoreServer,
        param: ProjectParam,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "tier_next", project = ?param.project);
        let pctx = param
            .project
            .as_deref()
            .and_then(|s| service.state.registry.get(s))
            .unwrap_or_else(|| service.state.registry.primary());
        let ops = pctx.ops();
        let projection = ontoref_ops::capabilities::tier_projection(ops.tier);
        serde_json::to_value(projection).map_err(ToolError::from)
    }
}

// ── Memory tools (component J, ADR-051/053) ─────────────────────────────────

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_chronicle",
    description = "Chronicle timeline: Accepted ADRs and protocol migrations, newest first.",
    category = "memory",
    params = "project:string:optional:Project slug; since:string:optional:ISO date lower bound \
              (e.g. 2026-01-01); kind:string:optional:adr|migration; \
              domain:string:optional:Domain filter"
)]
struct ChronicleListTool;

impl ToolBase for ChronicleListTool {
    type Parameter = ChronicleInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_chronicle".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some("Chronicle timeline: Accepted ADRs and protocol migrations, newest first.".into())
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for ChronicleListTool {
    async fn invoke(
        service: &OntoreServer,
        param: ChronicleInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "chronicle", project = ?param.project);
        let ctx = service.project_ctx(param.project.as_deref());
        let entries = crate::memory::chronicle_entries(
            &ctx.root,
            &ctx.cache,
            ctx.import_path.as_deref(),
            param.since.as_deref(),
            param.kind.as_deref(),
            param.domain.as_deref(),
        )
        .await;
        serde_json::to_value(serde_json::json!({ "count": entries.len(), "entries": entries }))
            .map_err(ToolError::from)
    }
}

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_roadmap",
    description = "Roadmap items derived from the project backlog.",
    category = "memory",
    params = "project:string:optional:Project slug; \
              status:string:optional:Open|InProgress|Done|Cancelled; \
              horizon:string:optional:now|next|later"
)]
struct RoadmapListTool;

impl ToolBase for RoadmapListTool {
    type Parameter = RoadmapQueryInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_roadmap".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some("Roadmap items derived from the project backlog.".into())
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for RoadmapListTool {
    async fn invoke(
        service: &OntoreServer,
        param: RoadmapQueryInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "roadmap", project = ?param.project);
        let ctx = service.project_ctx(param.project.as_deref());
        let items = crate::memory::roadmap_items(
            &ctx.root,
            &ctx.cache,
            ctx.import_path.as_deref(),
            param.status.as_deref(),
            param.horizon.as_deref(),
        )
        .await;
        serde_json::to_value(serde_json::json!({ "count": items.len(), "items": items }))
            .map_err(ToolError::from)
    }
}

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_criteria",
    description = "Criteria promoted to catalog validators (ADR-049/050).",
    category = "memory",
    params = "project:string:optional:Project slug; kind:string:optional:normative"
)]
struct CriteriaListTool;

impl ToolBase for CriteriaListTool {
    type Parameter = CriteriaInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_criteria".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some("Criteria promoted to catalog validators (ADR-049/050).".into())
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for CriteriaListTool {
    async fn invoke(
        service: &OntoreServer,
        param: CriteriaInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "criteria", project = ?param.project);
        let ctx = service.project_ctx(param.project.as_deref());
        let criteria = crate::memory::criteria_list(
            &ctx.root,
            &ctx.cache,
            ctx.import_path.as_deref(),
            param.kind.as_deref(),
        )
        .await;
        serde_json::to_value(serde_json::json!({ "count": criteria.len(), "criteria": criteria }))
            .map_err(ToolError::from)
    }
}

#[ontoref_derive::onto_mcp_tool(
    name = "ontoref_panels",
    description = "Resolved panel specs from the presentation directory (ADR-053).",
    category = "memory",
    params = "project:string:optional:Project slug; \
              kind:string:optional:Timeline|Gantt|Graph|Table|Board"
)]
struct PanelsListTool;

impl ToolBase for PanelsListTool {
    type Parameter = PanelsInput;
    type Output = serde_json::Value;
    type Error = ToolError;

    fn name() -> Cow<'static, str> {
        "ontoref_panels".into()
    }

    fn description() -> Option<Cow<'static, str>> {
        Some("Resolved panel specs from the presentation directory (ADR-053).".into())
    }

    fn output_schema() -> Option<Arc<JsonObject>> {
        None
    }
}

impl AsyncTool<OntoreServer> for PanelsListTool {
    async fn invoke(
        service: &OntoreServer,
        param: PanelsInput,
    ) -> Result<serde_json::Value, ToolError> {
        debug!(tool = "panels", project = ?param.project);
        let ctx = service.project_ctx(param.project.as_deref());
        let panels = crate::memory::panels_list(
            &ctx.root,
            &ctx.cache,
            ctx.import_path.as_deref(),
            param.kind.as_deref(),
        )
        .await;
        serde_json::to_value(serde_json::json!({ "count": panels.len(), "panels": panels }))
            .map_err(ToolError::from)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn catalog_contains_known_tools_and_unique_names() {
        let entries = catalog();
        assert!(
            entries.len() >= 34,
            "expected at least 34 MCP tools in inventory, found {}",
            entries.len()
        );

        let names: Vec<&str> = entries.iter().map(|t| t.name).collect();
        assert!(names.contains(&"ontoref_help"));
        assert!(names.contains(&"ontoref_guides"));
        assert!(names.contains(&"ontoref_status"));
        assert!(names.contains(&"ontoref_api_catalog"));
        assert!(names.contains(&"ontoref_positioning_get"));
        assert!(names.contains(&"ontoref_positioning_get_value_prop"));
        assert!(names.contains(&"ontoref_positioning_audit"));
        assert!(names.contains(&"ontoref_config_view"));
        assert!(names.contains(&"ontoref_tier_status"));
        assert!(names.contains(&"ontoref_tier_next"));
        assert!(names.contains(&"ontoref_chronicle"));
        assert!(names.contains(&"ontoref_roadmap"));
        assert!(names.contains(&"ontoref_criteria"));
        assert!(names.contains(&"ontoref_panels"));

        let mut sorted_unique = names.clone();
        sorted_unique.sort_unstable();
        sorted_unique.dedup();
        assert_eq!(
            sorted_unique.len(),
            names.len(),
            "duplicate tool names in inventory: {names:?}"
        );

        for name in &names {
            assert!(
                name.starts_with("ontoref_"),
                "tool name '{name}' does not start with 'ontoref_'"
            );
        }
    }
}
