cordance-cli 0.1.2

Cordance CLI — installs the `cordance` binary. The umbrella package `cordance` re-exports this entry; either install command works.
Documentation
//! `CordanceServer` — the rmcp `ServerHandler` that wires every Cordance MCP
//! tool to the stdio transport.
//!
//! The tool bodies live in [`super::tools`]; this module is plumbing only.
//! Every `#[tool]` here:
//! 1. canonicalises the caller's `target` against the allow-listed roots,
//! 2. delegates to the matching pure function in [`super::tools`],
//! 3. wraps the result as a `Json<T>` so rmcp returns structured content.

use std::sync::Arc;

use rmcp::handler::server::wrapper::{Json, Parameters};
use rmcp::model::{
    Implementation, InitializeResult, ProtocolVersion, ServerCapabilities, ServerInfo,
};
use rmcp::transport::stdio;
use rmcp::{tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler, ServiceExt};

use crate::config::Config;
use crate::mcp::error::McpToolError;
use crate::mcp::tools;
use crate::mcp::validation::{validate_target, AllowedRoots};

/// Stable list of tool names this server registers. Tests pin against this
/// to detect drift (and to enforce the adversarial-review exclusions).
#[allow(dead_code)]
pub const TOOL_NAMES: &[&str] = &[
    // Session tier
    "cordance_context_summary",
    "cordance_context_list_sources",
    "cordance_context_source_info",
    "cordance_advise_findings",
    "cordance_advise_by_rule",
    "cordance_check_drift",
    "cordance_doctrine_topics",
    "cordance_doctrine_lookup",
    "cordance_harness_target",
    "cordance_evidence_lookup",
    // Supervised tier
    "cordance_pack_dry_run",
    "cordance_advise_run",
    // Authority-bearing tier
    "cordance_cortex_receipt",
];

#[derive(Clone)]
pub struct CordanceServer {
    config: Arc<Config>,
    allowed_roots: Arc<AllowedRoots>,
}

impl CordanceServer {
    pub fn new(config: Config, allowed_roots: AllowedRoots) -> Self {
        Self {
            config: Arc::new(config),
            allowed_roots: Arc::new(allowed_roots),
        }
    }

    /// Resolve the optional `target` argument every tool accepts. `None`
    /// means "the server's working directory" (i.e. the first allow-listed
    /// root, which is always the launch CWD when the allow-list was built
    /// by [`AllowedRoots::from_config`]).
    fn resolve_target(&self, raw: Option<&str>) -> Result<camino::Utf8PathBuf, McpError> {
        let r = raw.unwrap_or(".");
        validate_target(r, &self.allowed_roots).map_err(Into::into)
    }
}

#[tool_router]
impl CordanceServer {
    // ---------- Session tier (read-only, no side effects) ----------------

    #[tool(
        name = "cordance_context_summary",
        description = "Return a deterministic summary of the current project's context pack: \
                       project name/kind, source count, classification breakdown, doctrine pin, \
                       and axiom algorithm version. No file body content."
    )]
    async fn cordance_context_summary(
        &self,
        Parameters(p): Parameters<tools::context::ContextSummaryParams>,
    ) -> Result<Json<tools::context::ContextSummaryOutput>, McpError> {
        let target = self.resolve_target(p.target.as_deref())?;
        let out = tools::context::summary(&target, &self.config).map_err(McpError::from)?;
        Ok(Json(out))
    }

    #[tool(
        name = "cordance_context_list_sources",
        description = "Paginated metadata-only listing of source records. Each record carries \
                       path, class, sha256, size, and blocked flag — never file content. \
                       Default page size 100, max 1000."
    )]
    async fn cordance_context_list_sources(
        &self,
        Parameters(p): Parameters<tools::context::ListSourcesParams>,
    ) -> Result<Json<tools::context::ListSourcesOutput>, McpError> {
        let target = self.resolve_target(p.target.as_deref())?;
        let out = tools::context::list_sources(&target, &self.config, p.cursor.as_deref(), p.limit)
            .map_err(McpError::from)?;
        Ok(Json(out))
    }

    #[tool(
        name = "cordance_context_source_info",
        description = "Fetch a single source record's metadata by stable id \
                       ({class}:{path}). No file content."
    )]
    async fn cordance_context_source_info(
        &self,
        Parameters(p): Parameters<tools::context::SourceInfoParams>,
    ) -> Result<Json<tools::context::SourceInfoOutput>, McpError> {
        let target = self.resolve_target(p.target.as_deref())?;
        let out =
            tools::context::source_info(&target, &self.config, &p.id).map_err(McpError::from)?;
        Ok(Json(out))
    }

    #[tool(
        name = "cordance_advise_findings",
        description = "Return all current deterministic advise findings for the target's pack."
    )]
    async fn cordance_advise_findings(
        &self,
        Parameters(p): Parameters<tools::advise::AdviseFindingsParams>,
    ) -> Result<Json<tools::advise::AdviseReportOutput>, McpError> {
        let target = self.resolve_target(p.target.as_deref())?;
        let out = tools::advise::findings(&target, &self.config).map_err(McpError::from)?;
        Ok(Json(out))
    }

    #[tool(
        name = "cordance_advise_by_rule",
        description = "Filter the current advise report by rule_id substring (case-sensitive)."
    )]
    async fn cordance_advise_by_rule(
        &self,
        Parameters(p): Parameters<tools::advise::AdviseByRuleParams>,
    ) -> Result<Json<tools::advise::AdviseReportOutput>, McpError> {
        let target = self.resolve_target(p.target.as_deref())?;
        let out =
            tools::advise::by_rule(&target, &self.config, &p.rule_id).map_err(McpError::from)?;
        Ok(Json(out))
    }

    #[tool(
        name = "cordance_check_drift",
        description = "Compute drift between the on-disk pack and `.cordance/sources.lock`. \
                       Returns clean/dirty plus per-source and per-output drift entries."
    )]
    async fn cordance_check_drift(
        &self,
        Parameters(p): Parameters<tools::check::CheckDriftParams>,
    ) -> Result<Json<tools::check::CheckDriftOutput>, McpError> {
        let target = self.resolve_target(p.target.as_deref())?;
        let out = tools::check::drift(&target).map_err(McpError::from)?;
        Ok(Json(out))
    }

    #[tool(
        name = "cordance_doctrine_topics",
        description = "List engineering-doctrine topics by section (principles, patterns, \
                       checklists, tooling). Returns topic name, path, sha256 — never file body."
    )]
    async fn cordance_doctrine_topics(
        &self,
        Parameters(p): Parameters<tools::doctrine::TopicsParams>,
    ) -> Result<Json<tools::doctrine::TopicsOutput>, McpError> {
        let target = self.resolve_target(p.target.as_deref())?;
        let out = tools::doctrine::topics(&target, &self.config).map_err(McpError::from)?;
        Ok(Json(out))
    }

    #[tool(
        name = "cordance_doctrine_lookup",
        description = "Resolve a doctrine topic name (filename stem or substring) to its \
                       index entry. Never returns the markdown body."
    )]
    async fn cordance_doctrine_lookup(
        &self,
        Parameters(p): Parameters<tools::doctrine::LookupParams>,
    ) -> Result<Json<tools::doctrine::LookupOutput>, McpError> {
        let target = self.resolve_target(p.target.as_deref())?;
        let out =
            tools::doctrine::lookup(&target, &self.config, &p.topic).map_err(McpError::from)?;
        Ok(Json(out))
    }

    #[tool(
        name = "cordance_harness_target",
        description = "Return the pai-axiom-project-harness-target.v1 JSON Cordance would emit \
                       for this target. Read-only; never writes the file."
    )]
    async fn cordance_harness_target(
        &self,
        Parameters(p): Parameters<tools::harness::HarnessTargetParams>,
    ) -> Result<Json<tools::harness::HarnessTargetOutput>, McpError> {
        let target = self.resolve_target(p.target.as_deref())?;
        let out = tools::harness::harness_target(&target, &self.config).map_err(McpError::from)?;
        Ok(Json(out))
    }

    #[tool(
        name = "cordance_evidence_lookup",
        description = "Look up evidence map entries by rule_id substring. Each entry cites the \
                       doctrine/ADR/schema/scan source that produced the rule."
    )]
    async fn cordance_evidence_lookup(
        &self,
        Parameters(p): Parameters<tools::evidence::EvidenceLookupParams>,
    ) -> Result<Json<tools::evidence::EvidenceLookupOutput>, McpError> {
        let target = self.resolve_target(p.target.as_deref())?;
        let out =
            tools::evidence::lookup(&target, &self.config, &p.rule_id).map_err(McpError::from)?;
        Ok(Json(out))
    }

    // ---------- Supervised tier (executes; logs to stderr) ---------------

    #[tool(
        name = "cordance_pack_dry_run",
        description = "Run cordance pack in dry-run mode. Returns planned output metadata \
                       (path, sha256, byte count, source anchors) — never the generated bytes."
    )]
    async fn cordance_pack_dry_run(
        &self,
        Parameters(p): Parameters<tools::pack::PackDryRunParams>,
    ) -> Result<Json<tools::pack::PackDryRunOutput>, McpError> {
        let target = self.resolve_target(p.target.as_deref())?;
        tracing::info!(target = %target, "cordance_pack_dry_run invoked");
        let out = tools::pack::dry_run(&target, &self.config, p.targets.as_deref())
            .map_err(McpError::from)?;
        Ok(Json(out))
    }

    #[tool(
        name = "cordance_advise_run",
        description = "Force a fresh deterministic advise pass against the target. Equivalent \
                       to running `cordance advise` on the command line."
    )]
    async fn cordance_advise_run(
        &self,
        Parameters(p): Parameters<tools::advise::AdviseRunParams>,
    ) -> Result<Json<tools::advise::AdviseReportOutput>, McpError> {
        let target = self.resolve_target(p.target.as_deref())?;
        tracing::info!(target = %target, "cordance_advise_run invoked");
        let out = tools::advise::run(&target, &self.config).map_err(McpError::from)?;
        Ok(Json(out))
    }

    // ---------- Authority-bearing tier (writes .cordance/) ---------------

    #[tool(
        name = "cordance_cortex_receipt",
        description = "Generate a cordance-cortex-receipt-v1-candidate. Writes it to \
                       .cordance/cortex-receipt.json unless `dry_run` is true. Returns the \
                       receipt inline. AuthorityBoundary is always candidate_only — Cordance \
                       cannot grant Cortex any promotion authority."
    )]
    async fn cordance_cortex_receipt(
        &self,
        Parameters(p): Parameters<tools::cortex::CortexReceiptParams>,
    ) -> Result<Json<tools::cortex::CortexReceiptOutput>, McpError> {
        let target = self.resolve_target(p.target.as_deref())?;
        tracing::info!(
            target = %target,
            dry_run = p.dry_run,
            "cordance_cortex_receipt invoked"
        );
        let out =
            tools::cortex::receipt(&target, &self.config, p.dry_run).map_err(McpError::from)?;
        Ok(Json(out))
    }
}

#[tool_handler]
impl ServerHandler for CordanceServer {
    fn get_info(&self) -> ServerInfo {
        let capabilities = ServerCapabilities::builder().enable_tools().build();
        let server_info = Implementation::new(
            "cordance".to_string(),
            env!("CARGO_PKG_VERSION").to_string(),
        );
        let instructions = "Cordance never invokes Cortex directly (ADR 0005). The cortex \
                            receipt is produced as a candidate the operator hands to Cortex's \
                            own admission flow. Doctrine, ADR, and source bodies are never \
                            returned over MCP — only metadata.";
        InitializeResult::new(capabilities)
            .with_protocol_version(ProtocolVersion::V_2025_06_18)
            .with_server_info(server_info)
            .with_instructions(instructions)
    }
}

/// Spawn the stdio MCP server.
///
/// All tracing/log output is routed to stderr by the caller in
/// [`crate::serve_cmd::run`]; stdout is reserved for JSON-RPC frames.
pub async fn serve_stdio(server: CordanceServer) -> Result<(), McpToolError> {
    let service = server
        .serve(stdio())
        .await
        .map_err(|e| McpToolError::Internal(format!("serve_stdio init failed: {e}")))?;
    service
        .waiting()
        .await
        .map_err(|e| McpToolError::Internal(format!("serve_stdio loop failed: {e}")))?;
    Ok(())
}

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

    #[test]
    fn tool_names_are_unique() {
        let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
        for name in TOOL_NAMES {
            assert!(seen.insert(name), "duplicate tool name: {name}");
        }
    }

    #[test]
    fn tool_names_count_matches_design_doc() {
        assert_eq!(
            TOOL_NAMES.len(),
            13,
            "Cordance v1 ships exactly 13 MCP tools"
        );
    }

    #[test]
    fn excluded_tools_are_not_registered() {
        for forbidden in [
            "cordance_doctrine_read",
            "cordance_cortex_push",
            "cordance_scan",
        ] {
            assert!(
                !TOOL_NAMES.contains(&forbidden),
                "forbidden tool {forbidden} must not be registered (see MCP_ADVERSARIAL.md)"
            );
        }
    }
}