use async_trait::async_trait;
use rmcp::{
model::CallToolRequestParams,
transport::{ConfigureCommandExt, TokioChildProcess},
ServiceExt,
};
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use tokio::sync::Mutex as AsyncMutex;
#[async_trait]
pub trait CxpakClient: Send + Sync {
async fn call(&self, tool: &str, args: Value) -> Option<Value>;
}
pub struct RecordedCxpakClient {
recordings: HashMap<String, Value>,
}
impl RecordedCxpakClient {
pub fn new(recordings: HashMap<String, Value>) -> Self {
Self { recordings }
}
pub fn from_dir(dir: &std::path::Path) -> std::io::Result<Self> {
let mut recordings = HashMap::new();
for entry in std::fs::read_dir(dir)? {
let Ok(entry) = entry else { continue };
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
let Ok(raw) = std::fs::read_to_string(&path) else {
continue;
};
if let Ok(v) = serde_json::from_str::<Value>(&raw) {
recordings.insert(stem.to_string(), v);
}
}
}
Ok(Self { recordings })
}
}
#[async_trait]
impl CxpakClient for RecordedCxpakClient {
async fn call(&self, tool: &str, _args: Value) -> Option<Value> {
self.recordings.get(tool).cloned()
}
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Health {
#[serde(default)]
pub conventions: f64,
#[serde(default)]
pub dead_code: Option<f64>,
#[serde(default)]
pub composite: Option<f64>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct DeadSymbol {
#[serde(default)]
pub file: String,
#[serde(default, alias = "symbol")]
pub name: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct DeadCode {
#[serde(default)]
pub dead_symbols: Vec<DeadSymbol>,
#[serde(default, alias = "total")]
pub total_scanned: u64,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Predict {
#[serde(default)]
pub risk_score: f64,
#[serde(default)]
pub test_predictions: Vec<Value>,
#[serde(default)]
pub structural_impact: Vec<Value>,
#[serde(default)]
pub call_impact: Vec<Value>,
#[serde(default)]
pub historical_impact: Vec<Value>,
#[serde(default)]
pub test_impact: Vec<Value>,
#[serde(default)]
pub confidence_summary: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct CallGraph {
#[serde(default)]
pub unresolved: Vec<Value>,
#[serde(default)]
pub edges: Vec<Value>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Violation {
#[serde(default)]
pub file: String,
#[serde(default)]
pub line: Option<u64>,
#[serde(default)]
pub rule: String,
#[serde(default)]
pub message: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Verify {
#[serde(default)]
pub violations: Vec<Violation>,
#[serde(default)]
pub files_checked: u64,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ArchModule {
#[serde(default)]
pub boundary_violations: Vec<Value>,
#[serde(default)]
pub god_files: Vec<Value>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Architecture {
#[serde(default)]
pub circular_deps: Vec<Value>,
#[serde(default)]
pub modules: Vec<ArchModule>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct SecuritySurface {
#[serde(default)]
pub secret_patterns: Vec<Value>,
#[serde(default)]
pub sql_injection_surface: Vec<Value>,
#[serde(default)]
pub unprotected_endpoints: Vec<Value>,
}
pub struct SpawnBackoff {
max: u32,
consecutive: u32,
}
impl SpawnBackoff {
pub fn new(max: u32) -> Self {
Self {
max,
consecutive: 0,
}
}
pub fn may_spawn(&self) -> bool {
self.consecutive < self.max
}
pub fn record_exit(&mut self, served_one: bool) {
if served_one {
self.consecutive = 0;
} else {
self.consecutive += 1;
}
}
pub fn strikes(&self) -> u32 {
self.consecutive
}
pub fn reset(&mut self) {
self.consecutive = 0;
}
}
const INDEX_WARM_BUDGET: std::time::Duration = std::time::Duration::from_secs(25);
const INDEX_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500);
const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
fn is_indexing(text: &str) -> bool {
text.contains("indexing in progress")
}
struct ActiveConn {
service: rmcp::service::RunningService<rmcp::service::RoleClient, ()>,
served_one: Arc<AtomicBool>,
child_pid: u32,
}
pub struct RmcpCxpakClient {
work_dir: std::path::PathBuf,
conn: AsyncMutex<Option<ActiveConn>>,
backoff: std::sync::Mutex<SpawnBackoff>,
closed: AtomicBool,
}
impl RmcpCxpakClient {
pub fn new(work_dir: std::path::PathBuf) -> Self {
Self {
work_dir,
conn: AsyncMutex::new(None),
backoff: std::sync::Mutex::new(SpawnBackoff::new(3)),
closed: AtomicBool::new(false),
}
}
}
#[async_trait]
impl CxpakClient for RmcpCxpakClient {
async fn call(&self, tool: &str, args: Value) -> Option<Value> {
if self.closed.load(Ordering::Relaxed) {
return None;
}
let (peer, served_one) = {
let mut guard = self.conn.lock().await;
if guard.is_none() {
{
let backoff = self.backoff.lock().unwrap();
if !backoff.may_spawn() {
return None;
}
}
let transport = match TokioChildProcess::builder(
tokio::process::Command::new("cxpak").configure(|cmd| {
cmd.arg("serve").arg("--mcp").arg(&self.work_dir);
}),
)
.stderr(std::process::Stdio::null())
.spawn()
{
Ok((t, _stderr)) => t,
Err(_) => {
self.backoff.lock().unwrap().record_exit(false);
return None;
}
};
let child_pid = match transport.id() {
Some(pid) => pid,
None => {
self.backoff.lock().unwrap().record_exit(false);
return None;
}
};
match tokio::time::timeout(HANDSHAKE_TIMEOUT, ().serve(transport)).await {
Ok(Ok(service)) => {
*guard = Some(ActiveConn {
service,
served_one: Arc::new(AtomicBool::new(false)),
child_pid,
});
}
Ok(Err(_)) | Err(_) => {
kill_child(child_pid);
self.backoff.lock().unwrap().record_exit(false);
return None;
}
}
}
let conn = guard
.as_mut()
.expect("conn invariant: spawn block sets Some or returns early");
(conn.service.peer().clone(), Arc::clone(&conn.served_one))
};
let tool_name = tool.to_owned();
let deadline = tokio::time::Instant::now() + INDEX_WARM_BUDGET;
loop {
let params = match &args {
Value::Object(map) => {
CallToolRequestParams::new(tool_name.clone()).with_arguments(map.clone())
}
_ => CallToolRequestParams::new(tool_name.clone()),
};
let timed =
tokio::time::timeout(std::time::Duration::from_secs(10), peer.call_tool(params))
.await;
match timed {
Ok(Ok(result)) => {
let text = result
.content
.first()
.and_then(|c| c.as_text())
.map(|t| t.text.clone())?;
if is_indexing(&text) {
if tokio::time::Instant::now() >= deadline {
return None;
}
tokio::time::sleep(INDEX_POLL_INTERVAL).await;
continue;
}
return match serde_json::from_str::<Value>(&text) {
Ok(parsed) => {
served_one.store(true, Ordering::Relaxed);
Some(parsed)
}
Err(_) => None,
};
}
Ok(Err(_)) | Err(_) => {
let mut guard = self.conn.lock().await;
let is_same_conn = guard
.as_ref()
.map(|c| Arc::ptr_eq(&c.served_one, &served_one))
.unwrap_or(false);
if is_same_conn {
let conn = guard
.take()
.expect("conn invariant: checked Some immediately above");
let did_serve = conn.served_one.load(Ordering::Relaxed);
kill_child(conn.child_pid);
drop(conn); if !self.closed.load(Ordering::Relaxed) {
self.backoff.lock().unwrap().record_exit(did_serve);
}
}
return None;
}
}
}
}
}
impl Drop for RmcpCxpakClient {
fn drop(&mut self) {
self.closed.store(true, Ordering::Relaxed);
if let Ok(mut guard) = self.conn.try_lock() {
if let Some(conn) = guard.take() {
kill_child(conn.child_pid);
drop(conn); }
}
}
}
#[cfg(unix)]
fn kill_child(pid: u32) {
let _ = unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) };
}
#[cfg(not(unix))]
fn kill_child(_pid: u32) {}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[tokio::test]
async fn recorded_client_replays_and_misses() {
let mut rec = std::collections::HashMap::new();
rec.insert("cxpak_health".to_string(), json!({ "conventions": 8.0 }));
let client = RecordedCxpakClient::new(rec);
let hit = client.call("cxpak_health", json!({})).await;
assert_eq!(hit, Some(json!({ "conventions": 8.0 })));
assert_eq!(client.call("cxpak_missing", json!({})).await, None);
}
#[test]
fn health_dto_defaults_are_tolerant() {
let h: Health = serde_json::from_value(json!({})).unwrap();
assert_eq!(h.conventions, 0.0);
assert!(h.dead_code.is_none());
}
#[test]
fn is_indexing_matches_cxpak_sentinel_only() {
assert!(is_indexing(
"cxpak: indexing in progress — Retry this call in a few seconds"
));
assert!(!is_indexing("{\"edges\":[],\"unresolved\":[]}"));
assert!(!is_indexing("indexing complete"));
}
#[test]
fn backoff_three_strikes_then_gives_up() {
let mut b = SpawnBackoff::new(3);
assert!(b.may_spawn());
b.record_exit(false); assert!(b.may_spawn());
b.record_exit(false); b.record_exit(false); assert!(!b.may_spawn(), "3 consecutive failures → give up");
b.record_exit(true); assert_eq!(b.strikes(), 0);
assert!(b.may_spawn());
}
}