arcature-cli 2026.2.0

Developer lifecycle CLI for Arcature applications.
Documentation
//! `config_schema` tool — the UAG schema descriptor.
//!
//! Returns the UAG schema version and the fields each UAG entry exposes,
//! so an MCP client can reason about the structure of the application
//! graph artifact. This is the **UAG schema**, not the application's
//! configuration schema: the latter (the app's `arcature.toml` +
//! environment + `#[model]` config) is deferred to AP2.1-6, which owns the
//! UAG's model/config entries. Honest naming: the tool describes what the
//! graph already carries, not what it does not yet carry (AGENTS.md §24:
//! no fake full compliance).
//!
//! Takes no arguments.

use crate::commands::mcp::capability::CapabilitySet;
use crate::commands::mcp::error::McpError;
use crate::commands::mcp::registry::ToolContext;
use arcature_build::uag::SCHEMA_VERSION;
use serde::Serialize;
use serde_json::Value;

#[derive(Serialize)]
struct ConfigSchemaResult {
    /// The schema version this CLI reads (`SCHEMA_VERSION`), so a client
    /// knows the artifact shape it will receive.
    schema_version: u32,
    /// What the `config_schema` tool describes — the UAG, not app config.
    describes: &'static str,
    /// The top-level UAG fields.
    uag_fields: &'static [&'static str],
    /// Fields a `RouteEntry` exposes.
    route_fields: &'static [&'static str],
    /// Fields a `ModuleEntry` exposes.
    module_fields: &'static [&'static str],
    /// Fields a `ServiceEntry` exposes.
    service_fields: &'static [&'static str],
    /// What is NOT covered (deferred to AP2.1-6 / AP2.1-7), honestly stated.
    not_covered: &'static [&'static str],
}

const UAG_FIELDS: &[&str] = &[
    "schema_version",
    "application",
    "framework_version",
    "modules",
    "routes",
    "services",
    "pages",
];
const ROUTE_FIELDS: &[&str] = &[
    "method",
    "path",
    "name",
    "handler",
    "pages",
    "action_fields",
    "action_type",
    "query_fields",
    "query_type",
    "query_array",
    "query_string_fields",
    "query_string_type",
];
const MODULE_FIELDS: &[&str] = &[
    "name",
    "imports",
    "exports",
    "controllers",
    "services",
    "policies",
    "routes",
    "listeners",
    "jobs",
    "commands",
    "schedules",
];
const SERVICE_FIELDS: &[&str] = &["name", "deps"];
const NOT_COVERED: &[&str] = &[
    "Application configuration schema (arcature.toml, environment, #[model] config) — deferred to AP2.1-6.",
    "Model/database-schema/migrations entries — deferred to AP2.1-6.",
    "System-check / log / trace sinks — deferred to AP2.1-7.",
];

pub(crate) fn call(
    _arguments: &Value,
    _capabilities: &CapabilitySet,
    _context: &ToolContext,
) -> Result<Value, McpError> {
    let result = ConfigSchemaResult {
        schema_version: SCHEMA_VERSION,
        describes: "Unified Application Graph (UAG) schema",
        uag_fields: UAG_FIELDS,
        route_fields: ROUTE_FIELDS,
        module_fields: MODULE_FIELDS,
        service_fields: SERVICE_FIELDS,
        not_covered: NOT_COVERED,
    };
    serde_json::to_value(result).map_err(McpError::from)
}

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

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

    #[test]
    fn returns_schema_version_and_field_lists() {
        let value = call(
            &Value::Null,
            &CapabilitySet::from_options(&crate::cli::McpOptions::default()),
            &ctx(),
        )
        .expect("ok");
        assert_eq!(value["schema_version"], SCHEMA_VERSION);
        assert_eq!(value["describes"], "Unified Application Graph (UAG) schema");
        let uag_fields = value["uag_fields"].as_array().expect("array");
        assert!(uag_fields.iter().any(|f| f == "routes"));
        assert!(uag_fields.iter().any(|f| f == "pages"));
    }

    #[test]
    fn honestly_lists_deferred_coverage() {
        let value = call(
            &Value::Null,
            &CapabilitySet::from_options(&crate::cli::McpOptions::default()),
            &ctx(),
        )
        .expect("ok");
        let not_covered = value["not_covered"].as_array().expect("array");
        // The tool must not claim app-config coverage it does not have.
        let joined = serde_json::to_string(not_covered).expect("serialize");
        assert!(joined.contains("AP2.1-6"), "{joined}");
        assert!(joined.contains("AP2.1-7"), "{joined}");
    }
}