arcature-cli 2026.1.1

Developer lifecycle CLI for Arcature applications.
Documentation
//! `arc mcp` — the Arcature MCP (Model Context Protocol) server over stdio
//! (AP2.1-9).
//!
//! The server reads JSON-RPC 2.0 requests from stdin, dispatches
//! `tools/call` requests to the registered tools, and writes JSON-RPC
//! responses to stdout — the standard MCP stdio transport. Every tool reads
//! the canonical Unified Application Graph (UAG) loaded from the on-disk
//! manifest (`.arcature/app-manifest.json`), never the source tree — the
//! cardinal rule (PROGRAM.md AP2.1-9: "MCP must not independently grep the
//! project to reconstruct what the graph already knows").
//!
//! # Safe defaults (master Reservation #4)
//!
//! * Read-only DB is the default capability.
//! * Arbitrary shell execution is **disabled** and **not a flag** — there is
//!   no way to enable it; a client requesting a `shell` capability is
//!   refused with a typed error ([`error::McpError::CapabilityRefused`]).
//!   No arbitrary command execution.
//! * Destructive writes are **capability-gated** — off by default, on only
//!   when `arc mcp --allow-destructive-writes` runs. The shipped read-only
//!   tools require no capabilities; the gate is the boundary future
//!   destructive tools require, exercised by negative tests now.
//! * Tool output is redacted (secrets in DB URLs, env values, connection
//!   strings) via the [`redact`] module, which reuses
//!   `arcature_observe::redact`'s `ErrorCategory` model (never relay an
//!   upstream `Display` verbatim; classify and drop).
//! * Tool argument sizes and route-name counts are bounded (§29 abuse
//!   limits) so an attacker cannot drive unbounded work through a tool call.
//!
//! # No new external dependency
//!
//! MCP-over-stdio is JSON-RPC 2.0 over stdin/stdout. The transport is
//! hand-rolled on `serde_json` (already a workspace dependency) — no MCP
//! SDK crate is admitted. A stock official MCP client must work without
//! knowing Arcature exists; this server speaks the standard JSON-RPC
//! envelope, not an Arcature protocol.
//!
//! # Source layout (AGENTS.md §1)
//!
//! One file = one responsibility. `capability.rs` owns the capability model;
//! `error.rs` owns the typed errors; `redact.rs` owns output secret
//! scrubbing; `transport.rs` owns the JSON-RPC wire types;
//! `registry.rs` owns tool registration; `dispatch.rs` owns request
//! dispatch; `server.rs` owns the stdio loop; `execute.rs` owns the CLI
//! entry point; the `tools/` folder holds one file per tool plus the shared
//! argument parser `tools/parse.rs`. This `mod.rs` declares the child
//! modules and re-exports the entry point only — no implementation lives
//! here (AGENTS.md §1).

pub(crate) mod capability;
pub(crate) mod dispatch;
pub(crate) mod error;
pub(crate) mod execute;
pub(crate) mod redact;
pub(crate) mod registry;
pub(crate) mod server;
pub(crate) mod tools;
pub(crate) mod transport;

pub(crate) use execute::execute;

#[cfg(test)]
mod no_grep_tests {
    //! The cardinal no-grep invariant (PROGRAM.md AP2.1-9: "MCP must not
    //! independently grep the project to reconstruct what the graph already
    //! knows").
    //!
    //! These tests are the structural proof that every MCP tool reads the
    //! canonical Unified Application Graph (UAG) handed to it in memory, NOT
    //! the filesystem. The proof is behavioral, not a source-text grep: each
    //! tool is dispatched through the real [`dispatch`] choke point with a
    //! `ToolContext` built from an in-memory fixture `Uag` carrying
    //! distinctive **canary** sentinels — and **no project on disk, no
    //! `.arcature/app-manifest.json`, no source tree, no filesystem access of
    //! any kind**. If any tool grepped the project, walked the source tree, or
    //! shelled out to reconstruct the graph, it would find nothing (no project
    //! exists in the test) and the canary would not appear in its result.
    //! Asserting every tool's JSON result contains the canary sourced from the
    //! in-memory UAG is therefore the strongest, most maintainable form of the
    //! no-grep proof: it would break the moment a tool grew a filesystem read.
    //!
    //! `docs_search` is included: it reads the compile-time-embedded Certified
    //! Stack Contract (`include_str!`, not a runtime read) plus the UAG's
    //! `framework_version` for the cross-stack protocol coordinate, so its
    //! canary is the protocol version sourced from the in-memory UAG.
    use super::dispatch::DispatchOutcome;
    use super::dispatch::dispatch;
    use super::registry::ToolContext;
    use super::transport::{JSONRPC_VERSION, Request, RequestId, validate};
    use arcature_build::uag::Uag;
    use arcature_build::uag::schema::{
        CadenceEntry, CommandEntry, JobEntry, ListenerEntry, ModuleEntry, RouteEntry,
        ScheduleEntry, ServiceEntry,
    };
    use serde_json::Value;
    use std::collections::BTreeMap;

    /// Canary sentinels embedded in the in-memory fixture UAG. Every tool is
    /// asserted to surface at least one of these in its result — proving it
    /// read the in-memory UAG, not the filesystem.
    const APP_CANARY: &str = "NoGrepCanary";
    const FW_CANARY: &str = "canary-fw-2026.1.0";
    const ROUTE_NAME_CANARY: &str = "no_grep.canary";
    const ROUTE_PATH_CANARY: &str = "/canary/no-grep";
    const PAGE_CANARY: &str = "Canary/Page";
    const SERVICE_CANARY: &str = "CanaryService";
    const SERVICE_DEP_CANARY: &str = "CanaryDep";
    const JOB_CANARY: &str = "canary_job";
    const COMMAND_CANARY: &str = "canary:cmd";
    const SCHEDULE_CANARY: &str = "canary_sched";

    /// Build an in-memory fixture `ToolContext` carrying canary sentinels and
    /// **no project on disk**. There is no `ProjectConfig`, no
    /// `.arcature/app-manifest.json`, no source tree — only the in-memory UAG.
    fn canary_context() -> ToolContext {
        let route = RouteEntry {
            method: "get".into(),
            path: ROUTE_PATH_CANARY.into(),
            name: ROUTE_NAME_CANARY.into(),
            handler: "CanaryHandler::show".into(),
            pages: vec![PAGE_CANARY.into()],
            action_fields: vec![],
            action_type: String::new(),
            query_fields: vec![],
            query_type: String::new(),
            query_array: false,
            query_string_fields: vec![],
            query_string_type: String::new(),
        };
        let module = ModuleEntry {
            name: "CanaryModule".into(),
            imports: vec![],
            exports: vec![],
            controllers: vec!["CanaryController".into()],
            services: vec![SERVICE_CANARY.into()],
            policies: vec![],
            routes: vec![route.clone()],
            listeners: vec![ListenerEntry {
                event: "CanaryEvent".into(),
                listener: "on_canary".into(),
            }],
            jobs: vec![JobEntry {
                kind: JOB_CANARY.into(),
                version: 1,
                handler: "handle_canary".into(),
            }],
            commands: vec![CommandEntry {
                name: COMMAND_CANARY.into(),
                function: "canary_command".into(),
            }],
            schedules: vec![ScheduleEntry {
                job: SCHEDULE_CANARY.into(),
                version: 1,
                cadence: CadenceEntry::Every { seconds: 300 },
            }],
        };
        let mut modules = BTreeMap::new();
        modules.insert("CanaryModule".to_owned(), module);
        ToolContext {
            uag: Uag {
                schema_version: 1,
                application: APP_CANARY.into(),
                framework_version: FW_CANARY.into(),
                modules,
                routes: vec![route],
                services: vec![ServiceEntry {
                    name: SERVICE_CANARY.into(),
                    deps: vec![SERVICE_DEP_CANARY.into()],
                }],
                pages: vec![arcature_build::uag::schema::PageEntry {
                    name: PAGE_CANARY.into(),
                }],
            },
        }
    }

    /// Dispatch a `tools/call` request for `name` with `arguments` against the
    /// in-memory canary context (no filesystem). Returns the serialized
    /// response JSON so canary sentinels can be asserted in the wire output.
    fn call_tool(name: &str, arguments: Value) -> String {
        let request = Request {
            jsonrpc: JSONRPC_VERSION.to_owned(),
            id: Some(RequestId::Num(1)),
            method: "tools/call".to_owned(),
            params: serde_json::json!({ "name": name, "arguments": arguments }),
        };
        let parsed = validate(request).expect("valid request");
        let caps =
            super::capability::CapabilitySet::from_options(&crate::cli::McpOptions::default());
        let outcome = dispatch(parsed, &caps, &canary_context());
        match outcome {
            DispatchOutcome::Respond(resp) => serde_json::to_string(&resp).expect("serialize"),
            DispatchOutcome::Skip => "SKIP".to_owned(),
        }
    }

    /// Assert `hay` contains `needle`, with a message naming the tool/canary.
    fn assert_canary(tool: &str, hay: &str, needle: &str) {
        assert!(
            hay.contains(needle),
            "no-grep invariant: tool `{tool}` did not surface canary `{needle}` from the in-memory UAG (no filesystem). response: {hay}"
        );
    }

    #[test]
    fn application_info_reads_the_uag_not_the_filesystem() {
        let out = call_tool("application_info", Value::Object(Default::default()));
        assert_canary("application_info", &out, APP_CANARY);
        assert_canary("application_info", &out, FW_CANARY);
    }

    #[test]
    fn routes_reads_the_uag_not_the_filesystem() {
        let out = call_tool("routes", Value::Object(Default::default()));
        assert_canary("routes", &out, ROUTE_NAME_CANARY);
        assert_canary("routes", &out, ROUTE_PATH_CANARY);
    }

    #[test]
    fn inspect_route_reads_the_uag_not_the_filesystem() {
        let out = call_tool(
            "inspect_route",
            serde_json::json!({ "route_name": ROUTE_NAME_CANARY }),
        );
        assert_canary("inspect_route", &out, ROUTE_PATH_CANARY);
    }

    #[test]
    fn pages_reads_the_uag_not_the_filesystem() {
        let out = call_tool("pages", Value::Object(Default::default()));
        assert_canary("pages", &out, PAGE_CANARY);
    }

    #[test]
    fn services_reads_the_uag_not_the_filesystem() {
        let out = call_tool("services", Value::Object(Default::default()));
        assert_canary("services", &out, SERVICE_CANARY);
        assert_canary("services", &out, SERVICE_DEP_CANARY);
    }

    #[test]
    fn jobs_reads_the_uag_not_the_filesystem() {
        let out = call_tool("jobs", Value::Object(Default::default()));
        assert_canary("jobs", &out, JOB_CANARY);
        assert_canary("jobs", &out, COMMAND_CANARY);
        assert_canary("jobs", &out, SCHEDULE_CANARY);
    }

    #[test]
    fn docs_search_reads_the_uag_protocol_coordinate_not_the_filesystem() {
        // docs_search reads the compile-time-embedded Certified Stack Contract
        // (include_str!, not a runtime read) and the UAG's framework_version
        // for the cross-stack protocol coordinate. The canary is that
        // coordinate, sourced from the in-memory UAG — not the filesystem.
        let out = call_tool("docs_search", Value::Object(Default::default()));
        assert_canary("docs_search", &out, FW_CANARY);
    }

    #[test]
    fn every_registered_tool_works_with_no_filesystem() {
        // The sweeping form of the no-grep invariant: every tool in the
        // registry runs to completion (no error response) against an in-memory
        // context with no project on disk. A tool that needed the filesystem
        // would return an error response here (or panic, which would fail the
        // test). `config_schema` describes the UAG schema itself and reads no
        // UAG content; it is included to prove it also needs no filesystem.
        let caps =
            super::capability::CapabilitySet::from_options(&crate::cli::McpOptions::default());
        let ctx = canary_context();
        for tool in super::registry::all_tools() {
            let arguments = if tool.name == "inspect_route" {
                serde_json::json!({ "route_name": ROUTE_NAME_CANARY })
            } else {
                Value::Object(Default::default())
            };
            let request = Request {
                jsonrpc: JSONRPC_VERSION.to_owned(),
                id: Some(RequestId::Num(1)),
                method: "tools/call".to_owned(),
                params: serde_json::json!({ "name": tool.name, "arguments": arguments }),
            };
            let parsed = validate(request).expect("valid request");
            let outcome = dispatch(parsed, &caps, &ctx);
            let resp = match outcome {
                DispatchOutcome::Respond(r) => r,
                DispatchOutcome::Skip => panic!("tool `{}` returned Skip", tool.name),
            };
            assert!(
                resp.error.is_none(),
                "no-grep invariant: tool `{}` returned an error with no filesystem: {:?}",
                tool.name,
                resp.error
            );
        }
    }
}