use std::collections::BTreeMap;
use camino::Utf8PathBuf;
use cordance_core::pack::CordancePack;
use cordance_core::source::SourceRecord;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::mcp::error::{McpToolError, McpToolResult};
pub const MAX_SOURCES_PAGE: usize = 1_000;
pub const DEFAULT_SOURCES_PAGE: usize = 100;
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
pub struct ContextSummaryParams {
#[serde(default)]
pub target: Option<String>,
}
#[derive(Clone, Debug, Serialize, JsonSchema)]
pub struct ContextSummaryOutput {
pub schema: String,
pub project_name: String,
pub project_kind: String,
pub host_os: String,
pub source_count: usize,
pub classification_breakdown: BTreeMap<String, usize>,
pub doctrine_pin: Option<DoctrinePinSummary>,
pub axiom_algorithm_pin: String,
pub generated_at: String,
}
#[derive(Clone, Debug, Serialize, JsonSchema)]
pub struct DoctrinePinSummary {
pub repo: String,
pub commit: String,
#[schemars(with = "String")]
pub source_path: Utf8PathBuf,
}
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
pub struct ListSourcesParams {
#[serde(default)]
pub target: Option<String>,
#[serde(default)]
pub cursor: Option<String>,
#[serde(default)]
pub limit: Option<usize>,
}
#[derive(Clone, Debug, Serialize, JsonSchema)]
pub struct ListSourcesOutput {
pub schema: String,
pub total: usize,
pub records: Vec<SourceRecordSummary>,
pub next_cursor: Option<String>,
}
#[derive(Clone, Debug, Serialize, JsonSchema)]
pub struct SourceRecordSummary {
pub id: String,
#[schemars(with = "String")]
pub path: Utf8PathBuf,
pub class: String,
pub sha256: String,
pub size_bytes: u64,
pub blocked: bool,
}
#[derive(Clone, Debug, Deserialize, JsonSchema)]
pub struct SourceInfoParams {
#[serde(default)]
pub target: Option<String>,
pub id: String,
}
#[derive(Clone, Debug, Serialize, JsonSchema)]
pub struct SourceInfoOutput {
pub schema: String,
pub record: SourceRecordSummary,
}
pub fn summary(target: &Utf8PathBuf, cfg: &Config) -> McpToolResult<ContextSummaryOutput> {
let pack = build_pack(target, cfg)?;
Ok(ContextSummaryOutput {
schema: "cordance-context-summary.v1".to_string(),
project_name: pack.project.name.clone(),
project_kind: pack.project.kind.clone(),
host_os: pack.project.host_os.clone(),
source_count: pack.sources.len(),
classification_breakdown: classification_breakdown(&pack.sources),
doctrine_pin: pack.doctrine_pins.first().map(|pin| DoctrinePinSummary {
repo: pin.repo.clone(),
commit: pin.commit.clone(),
source_path: pin.source_path.clone(),
}),
axiom_algorithm_pin: cfg.axiom_version(target),
generated_at: chrono::Utc::now().to_rfc3339(),
})
}
pub fn list_sources(
target: &Utf8PathBuf,
cfg: &Config,
cursor: Option<&str>,
limit: Option<usize>,
) -> McpToolResult<ListSourcesOutput> {
let pack = build_pack(target, cfg)?;
let start = parse_cursor(cursor)?;
let cap = limit
.unwrap_or(DEFAULT_SOURCES_PAGE)
.clamp(1, MAX_SOURCES_PAGE);
let total = pack.sources.len();
if start >= total {
return Ok(ListSourcesOutput {
schema: "cordance-context-source-list.v1".to_string(),
total,
records: Vec::new(),
next_cursor: None,
});
}
let end = (start + cap).min(total);
let records: Vec<SourceRecordSummary> = pack
.sources
.iter()
.skip(start)
.take(end - start)
.map(summarise_record)
.collect();
let next_cursor = if end < total {
Some(format_cursor(end))
} else {
None
};
Ok(ListSourcesOutput {
schema: "cordance-context-source-list.v1".to_string(),
total,
records,
next_cursor,
})
}
pub fn source_info(
target: &Utf8PathBuf,
cfg: &Config,
id: &str,
) -> McpToolResult<SourceInfoOutput> {
let pack = build_pack(target, cfg)?;
let record = pack
.sources
.iter()
.find(|r| r.id == id)
.ok_or_else(|| McpToolError::NotFound(format!("no source with id {id}")))?;
Ok(SourceInfoOutput {
schema: "cordance-context-source-info.v1".to_string(),
record: summarise_record(record),
})
}
fn summarise_record(r: &SourceRecord) -> SourceRecordSummary {
SourceRecordSummary {
id: r.id.clone(),
path: r.path.clone(),
class: serialise_class(r),
sha256: r.sha256.clone(),
size_bytes: r.size_bytes,
blocked: r.blocked,
}
}
fn serialise_class(r: &SourceRecord) -> String {
serde_json::to_value(r.class)
.ok()
.and_then(|v| v.as_str().map(str::to_owned))
.unwrap_or_else(|| "unclassified".to_string())
}
fn classification_breakdown(records: &[SourceRecord]) -> BTreeMap<String, usize> {
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for r in records {
let key = serialise_class(r);
*counts.entry(key).or_insert(0) += 1;
}
counts
}
fn parse_cursor(cursor: Option<&str>) -> McpToolResult<usize> {
let Some(raw) = cursor else {
return Ok(0);
};
raw.parse::<usize>().map_err(|e| {
McpToolError::InvalidArgument(format!("cursor must be a non-negative integer: {e}"))
})
}
fn format_cursor(idx: usize) -> String {
idx.to_string()
}
pub fn build_pack(target: &Utf8PathBuf, cfg: &Config) -> McpToolResult<CordancePack> {
super::pack::build_pack(target, cfg)
}