arcature-cli 2026.2.0

Developer lifecycle CLI for Arcature applications.
Documentation
//! Tool registration — the static registry of MCP tools.
//!
//! One [`Tool`] per responsibility; one file per tool under [`tools`]. The
//! registry is a static slice built at compile time (no runtime discovery,
//! no `inventory`/`linkme` — AGENTS.md §17/§20). A tool is a name, a
//! description, the capabilities it requires, and a handler function.
//!
//! # §19: decide before expensive work
//!
//! The UAG is loaded **once** per session (file-preferred: reads
//! `.arcature/app-manifest.json`) and handed to every tool as a
//! [`ToolContext`]; tools never reload it per call. `docs_search` does not
//! touch the UAG and ignores it. A tool resolves only the slice of the UAG
//! its arguments require (e.g. `inspect_route` finds one route by name, not
//! all routes).

use serde_json::Value;

use crate::commands::mcp::capability::{Capability, CapabilitySet};
use crate::commands::mcp::error::McpError;
use crate::commands::mcp::tools;
use arcature_build::uag::Uag;

/// The shared, already-loaded context every tool receives. The UAG is
/// loaded once per session by the server (file-preferred) and handed here;
/// tools never reload it (§19). Tests build a [`ToolContext`] from a
/// fixture [`Uag`] directly, with no project discovery.
#[derive(Debug, Clone)]
pub(crate) struct ToolContext {
    pub(crate) uag: Uag,
}

impl ToolContext {
    /// Build a context for tests from a fixture UAG (no project, no load).
    #[cfg(test)]
    #[allow(dead_code)] // test-only fixture; used by some tool-test modules.
    pub(crate) fn from_uag(uag: Uag) -> Self {
        Self { uag }
    }
}

/// A registered MCP tool. Static metadata + a handler function pointer.
pub(crate) struct Tool {
    /// The stable tool name a client calls (`tools/call` `name` field).
    pub(crate) name: &'static str,
    /// A one-line description surfaced in `tools/list`.
    pub(crate) description: &'static str,
    /// Capabilities the tool requires to run. Empty for the read-only
    /// shipped tools; the capability gate refuses a call if any are not
    /// granted (master Reservation #4).
    pub(crate) required_capabilities: &'static [Capability],
    /// The handler. Receives the parsed params, the granted capability
    /// set, and the loaded tool context.
    pub(crate) handler:
        for<'a> fn(&'a Value, &'a CapabilitySet, &'a ToolContext) -> Result<Value, McpError>,
}

/// The full static registry of tools this server exposes. Add a tool by
/// adding a file under [`tools`] and an entry here.
pub(crate) fn all_tools() -> &'static [Tool] {
    &[
        Tool {
            name: "application_info",
            description: "Summary of the application graph: module/route/page/service counts, application name, framework version.",
            required_capabilities: &[],
            handler: tools::application_info::call,
        },
        Tool {
            name: "routes",
            description: "List all routes in the application graph (method, path, name, handler, pages).",
            required_capabilities: &[],
            handler: tools::routes::call,
        },
        Tool {
            name: "inspect_route",
            description: "Inspect a single route by its dotted name (e.g. links.show).",
            required_capabilities: &[],
            handler: tools::inspect_route::call,
        },
        Tool {
            name: "pages",
            description: "List page-contract identities in the application graph.",
            required_capabilities: &[],
            handler: tools::pages::call,
        },
        Tool {
            name: "services",
            description: "List services and their typed dependencies.",
            required_capabilities: &[],
            handler: tools::services::call,
        },
        Tool {
            name: "jobs",
            description: "List job handlers, commands, and scheduled jobs across modules.",
            required_capabilities: &[],
            handler: tools::jobs::call,
        },
        Tool {
            name: "config_schema",
            description: "Describe the UAG schema: schema version and the fields each UAG entry exposes. (App-config schema is deferred to AP2.1-6.)",
            required_capabilities: &[],
            handler: tools::config_schema::call,
        },
        Tool {
            name: "docs_search",
            description: "Version-aware docs retrieval: the certified stack components, their pinned versions, and the cross-stack protocol coordinates for the installed stack.",
            required_capabilities: &[],
            handler: tools::docs_search::call,
        },
    ]
}

/// Look up a tool by name. Returns `None` for an unknown tool (the dispatch
/// layer maps that to [`McpError::UnknownTool`]).
pub(crate) fn find(name: &str) -> Option<&'static Tool> {
    all_tools().iter().find(|tool| tool.name == name)
}

#[cfg(test)]
mod tests {
    use super::*;
    use arcature_build::uag::SCHEMA_VERSION;
    use std::collections::BTreeMap;

    fn empty_context() -> ToolContext {
        ToolContext {
            uag: Uag {
                schema_version: SCHEMA_VERSION,
                application: String::new(),
                framework_version: String::new(),
                modules: BTreeMap::new(),
                routes: vec![],
                services: vec![],
                pages: vec![],
            },
        }
    }

    #[test]
    fn every_tool_has_a_unique_nonempty_name() {
        let tools = all_tools();
        let names: Vec<&str> = tools.iter().map(|t| t.name).collect();
        let mut sorted = names.clone();
        sorted.sort();
        sorted.dedup();
        assert_eq!(names.len(), sorted.len(), "duplicate tool names");
        assert!(names.iter().all(|n| !n.is_empty()), "empty tool name");
    }

    #[test]
    fn every_tool_has_a_description_and_handler() {
        for tool in all_tools() {
            assert!(!tool.description.is_empty(), "{}", tool.name);
            // Handler is a non-null fn pointer by construction; just assert it
            // differs from a default (a no-op) to confirm it was assigned.
            let noop: fn(&Value, &CapabilitySet, &ToolContext) -> Result<Value, McpError> =
                |_, _, _| Ok(Value::Null);
            let _ = noop; // silence unused
            // The shipped tool names are the contract; assert the expected set.
        }
    }

    #[test]
    fn find_returns_known_tools() {
        assert!(find("application_info").is_some());
        assert!(find("inspect_route").is_some());
        assert!(find("docs_search").is_some());
        assert!(find("nope").is_none());
    }

    #[test]
    fn shipped_tools_require_no_capabilities() {
        // The read-only tools implemented this wave require no capabilities.
        for tool in all_tools() {
            assert!(
                tool.required_capabilities.is_empty(),
                "{} requires capabilities but is a read-only tool",
                tool.name
            );
        }
    }

    #[test]
    fn empty_context_is_usable() {
        let ctx = empty_context();
        assert_eq!(ctx.uag.schema_version, SCHEMA_VERSION);
        assert!(ctx.uag.routes.is_empty());
    }
}