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::{ErrorData as McpError, ServerHandler, ServiceExt, tool, tool_handler, tool_router};
use crate::config::Config;
use crate::mcp::error::McpToolError;
use crate::mcp::tools;
use crate::mcp::validation::{AllowedRoots, validate_target};
#[allow(dead_code)]
pub const TOOL_NAMES: &[&str] = &[
"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",
"cordance_pack_dry_run",
"cordance_advise_run",
"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),
}
}
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 {
#[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))
}
#[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))
}
#[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)
}
}
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)"
);
}
}
}