From 2e397a5b19de59a39751f76c17f67da0d2f2ea8f Mon Sep 17 00:00:00 2001 From: copyleftdev Date: Thu, 9 Apr 2026 22:56:20 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20add=20vajra-mcp=20crate=20=E2=80=94=20M?= =?UTF-8?q?CP=20server=20exposing=2019=20tools=20over=20JSON-RPC=202.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thin MCP (Model Context Protocol) server that shells out to the vajra binary for all 19 analysis, governance, pipeline, and meta commands. Implements the MCP 2025-03-26 spec with stdio line-delimited JSON transport. No async, no frameworks — just serde_json + stdin/stdout. Tools: vajra_inspect, vajra_stats, vajra_anomalies, vajra_fingerprint, vajra_essence, vajra_invariants, vajra_query, vajra_drift, vajra_cascade, vajra_batch, vajra_cluster, vajra_governance, vajra_core_team, vajra_score, vajra_compare, vajra_ingest_github, vajra_report, vajra_audit, vajra_profiles. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 8 + Cargo.toml | 1 + vajra-mcp/Cargo.toml | 14 + vajra-mcp/src/main.rs | 10 + vajra-mcp/src/protocol.rs | 220 ++++++++ vajra-mcp/src/router.rs | 125 +++++ vajra-mcp/src/tools/analyze.rs | 833 ++++++++++++++++++++++++++++++ vajra-mcp/src/tools/compare.rs | 99 ++++ vajra-mcp/src/tools/governance.rs | 226 ++++++++ vajra-mcp/src/tools/mod.rs | 160 ++++++ vajra-mcp/src/tools/pipeline.rs | 253 +++++++++ vajra-mcp/src/transport.rs | 76 +++ 12 files changed, 2025 insertions(+) create mode 100644 vajra-mcp/Cargo.toml create mode 100644 vajra-mcp/src/main.rs create mode 100644 vajra-mcp/src/protocol.rs create mode 100644 vajra-mcp/src/router.rs create mode 100644 vajra-mcp/src/tools/analyze.rs create mode 100644 vajra-mcp/src/tools/compare.rs create mode 100644 vajra-mcp/src/tools/governance.rs create mode 100644 vajra-mcp/src/tools/mod.rs create mode 100644 vajra-mcp/src/tools/pipeline.rs create mode 100644 vajra-mcp/src/transport.rs diff --git a/Cargo.lock b/Cargo.lock index 472365e..351bd1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2008,6 +2008,14 @@ dependencies = [ "vajra-types", ] +[[package]] +name = "vajra-mcp" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "vajra-motif" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index 16ff1cb..29f95a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ members = [ "vajra-domain-encoding", "vajra-domain-github", "vajra-report", + "vajra-mcp", ] [workspace.package] diff --git a/vajra-mcp/Cargo.toml b/vajra-mcp/Cargo.toml new file mode 100644 index 0000000..97b2eb0 --- /dev/null +++ b/vajra-mcp/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "vajra-mcp" +version = "0.1.0" +edition = "2021" +description = "MCP (Model Context Protocol) server for vajra" +license = "MIT OR Apache-2.0" + +[[bin]] +name = "vajra-mcp" +path = "src/main.rs" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/vajra-mcp/src/main.rs b/vajra-mcp/src/main.rs new file mode 100644 index 0000000..8deccec --- /dev/null +++ b/vajra-mcp/src/main.rs @@ -0,0 +1,10 @@ +mod protocol; +mod router; +mod tools; +mod transport; + +fn main() { + let registry = tools::register_all(); + let router = router::Router::new(registry); + transport::run_stdio(router); +} diff --git a/vajra-mcp/src/protocol.rs b/vajra-mcp/src/protocol.rs new file mode 100644 index 0000000..958eecd --- /dev/null +++ b/vajra-mcp/src/protocol.rs @@ -0,0 +1,220 @@ +//! JSON-RPC 2.0 + MCP message types. +//! +//! Minimal implementation of the JSON-RPC 2.0 spec as used by MCP (2025-03-26). + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +// ───────────────────────────────────────────────── +// Request ID — number or string per JSON-RPC 2.0 +// ───────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum Id { + Number(i64), + Text(String), +} + +// ───────────────────────────────────────────────── +// Incoming message (request or notification) +// ───────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct JsonRpcMessage { + #[allow(dead_code)] + pub jsonrpc: String, + #[serde(default)] + pub id: Option, + pub method: Option, + #[serde(default)] + pub params: Option, +} + +impl JsonRpcMessage { + pub fn is_notification(&self) -> bool { + self.id.is_none() && self.method.is_some() + } +} + +// ───────────────────────────────────────────────── +// Outgoing response +// ───────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct JsonRpcResponse { + pub jsonrpc: &'static str, + pub id: Id, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl JsonRpcResponse { + pub fn success(id: Id, result: Value) -> Self { + Self { + jsonrpc: "2.0", + id, + result: Some(result), + error: None, + } + } + + pub fn error(id: Id, error: JsonRpcError) -> Self { + Self { + jsonrpc: "2.0", + id, + result: None, + error: Some(error), + } + } +} + +// ───────────────────────────────────────────────── +// Error object +// ───────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcError { + pub code: i32, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +pub const PARSE_ERROR: i32 = -32700; +pub const INVALID_REQUEST: i32 = -32600; +pub const METHOD_NOT_FOUND: i32 = -32601; +pub const INVALID_PARAMS: i32 = -32602; +pub const INTERNAL_ERROR: i32 = -32603; + +impl JsonRpcError { + pub fn parse_error(detail: &str) -> Self { + Self { + code: PARSE_ERROR, + message: format!("Parse error: {detail}"), + data: None, + } + } + + #[allow(dead_code)] + pub fn invalid_request(detail: &str) -> Self { + Self { + code: INVALID_REQUEST, + message: format!("Invalid request: {detail}"), + data: None, + } + } + + pub fn method_not_found(method: &str) -> Self { + Self { + code: METHOD_NOT_FOUND, + message: format!("Method not found: {method}"), + data: None, + } + } + + pub fn invalid_params(detail: &str) -> Self { + Self { + code: INVALID_PARAMS, + message: format!("Invalid params: {detail}"), + data: None, + } + } + + pub fn internal(detail: &str) -> Self { + Self { + code: INTERNAL_ERROR, + message: format!("Internal error: {detail}"), + data: None, + } + } +} + +// ───────────────────────────────────────────────── +// MCP types +// ───────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InitializeResult { + pub protocol_version: &'static str, + pub capabilities: ServerCapabilities, + pub server_info: ServerInfo, + pub instructions: Option, +} + +#[derive(Debug, Serialize)] +pub struct ServerCapabilities { + pub tools: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolsCapability { + pub list_changed: bool, +} + +#[derive(Debug, Serialize)] +pub struct ServerInfo { + pub name: String, + pub version: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolListResult { + pub tools: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolDescriptor { + pub name: String, + pub description: String, + pub input_schema: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub annotations: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CallToolParams { + pub name: String, + #[serde(default)] + pub arguments: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CallToolResult { + pub content: Vec, + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub is_error: bool, +} + +impl CallToolResult { + pub fn text(text: String) -> Self { + Self { + content: vec![Content::Text { text }], + is_error: false, + } + } + + pub fn error(message: String) -> Self { + Self { + content: vec![Content::Text { text: message }], + is_error: true, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum Content { + #[serde(rename = "text")] + Text { text: String }, +} + +#[derive(Debug, Serialize)] +pub struct PingResult {} diff --git a/vajra-mcp/src/router.rs b/vajra-mcp/src/router.rs new file mode 100644 index 0000000..3d81231 --- /dev/null +++ b/vajra-mcp/src/router.rs @@ -0,0 +1,125 @@ +//! JSON-RPC method router. +//! +//! Dispatches incoming JSON-RPC messages to the appropriate handler: +//! initialize, tools/list, tools/call, ping, notifications. + +use serde_json::Value; + +use crate::protocol::*; +use crate::tools::ToolRegistry; + +pub struct Router { + tools: ToolRegistry, +} + +impl Router { + pub fn new(tools: ToolRegistry) -> Self { + Self { tools } + } + + pub fn handle(&self, msg: JsonRpcMessage) -> Option { + // Notifications (no id) don't get responses + if msg.is_notification() { + return None; + } + + // Requests require an id + let id = msg.id?; + + let method = match msg.method.as_deref() { + Some(m) => m, + None => { + return Some(JsonRpcResponse::error( + id, + JsonRpcError::invalid_request("missing method"), + )); + } + }; + + let params = msg.params.unwrap_or(Value::Object(serde_json::Map::new())); + + let result = match method { + "initialize" => self.handle_initialize(), + "ping" => self.handle_ping(), + "tools/list" => self.handle_tools_list(), + "tools/call" => self.handle_tools_call(params), + other => { + return Some(JsonRpcResponse::error( + id, + JsonRpcError::method_not_found(other), + )); + } + }; + + match result { + Ok(value) => Some(JsonRpcResponse::success(id, value)), + Err(err) => Some(JsonRpcResponse::error(id, err)), + } + } + + fn handle_initialize(&self) -> Result { + let result = InitializeResult { + protocol_version: "2025-03-26", + capabilities: ServerCapabilities { + tools: Some(ToolsCapability { + list_changed: false, + }), + }, + server_info: ServerInfo { + name: "vajra".into(), + version: env!("CARGO_PKG_VERSION").into(), + }, + instructions: Some( + "Vajra is a deterministic structural-analysis engine for data files \ + (JSON, YAML, CSV, NDJSON, Markdown, PDF, source code, git repos). \ + It provides structural inspection, statistical summaries, anomaly \ + detection, fingerprinting, drift detection, governance metrics, \ + health scoring, and full GitHub repository auditing. \ + Start with `vajra_inspect` to understand a file's structure, \ + then use `vajra_stats`, `vajra_anomalies`, or `vajra_essence` \ + for deeper analysis. Use `vajra_audit` for one-command GitHub \ + repository health reports." + .into(), + ), + }; + + serde_json::to_value(result).map_err(|e| JsonRpcError::internal(&e.to_string())) + } + + fn handle_ping(&self) -> Result { + serde_json::to_value(PingResult {}).map_err(|e| JsonRpcError::internal(&e.to_string())) + } + + fn handle_tools_list(&self) -> Result { + let result = ToolListResult { + tools: self.tools.descriptors(), + }; + serde_json::to_value(result).map_err(|e| JsonRpcError::internal(&e.to_string())) + } + + fn handle_tools_call(&self, params: Value) -> Result { + let call: CallToolParams = serde_json::from_value(params) + .map_err(|e| JsonRpcError::invalid_params(&e.to_string()))?; + + let tool = self + .tools + .get(&call.name) + .ok_or_else(|| JsonRpcError::invalid_params(&format!("unknown tool: {}", call.name)))?; + + let tool_params = call + .arguments + .unwrap_or(Value::Object(serde_json::Map::new())); + + let result = tool.call(tool_params); + + match result { + Ok(call_result) => serde_json::to_value(call_result) + .map_err(|e| JsonRpcError::internal(&e.to_string())), + Err(tool_err) => { + let call_result = CallToolResult::error(tool_err.to_string()); + serde_json::to_value(call_result) + .map_err(|e| JsonRpcError::internal(&e.to_string())) + } + } + } +} diff --git a/vajra-mcp/src/tools/analyze.rs b/vajra-mcp/src/tools/analyze.rs new file mode 100644 index 0000000..cf0cd38 --- /dev/null +++ b/vajra-mcp/src/tools/analyze.rs @@ -0,0 +1,833 @@ +//! Analysis tools — read-only, idempotent operations on data files. + +use std::process::Command; + +use serde_json::{json, Value}; + +use super::{run_vajra, vajra_bin, CallToolResult, Tool, ToolError}; + +// ───────────────────────────────────────────────── +// vajra_inspect +// ───────────────────────────────────────────────── + +pub struct InspectTool; + +impl Tool for InspectTool { + fn name(&self) -> &'static str { + "vajra_inspect" + } + + fn description(&self) -> &'static str { + "Full structural analysis of a data file — paths, types, depth, \ + BLAKE3 fingerprints, domain hints. Supports JSON, YAML, CSV, NDJSON, \ + Markdown, PDF, source code, git repos." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to file or directory to analyze" + }, + "format": { + "type": "string", + "enum": ["json", "text", "markdown", "compact-ai"], + "default": "json", + "description": "Output format" + }, + "input_format": { + "type": "string", + "enum": ["json", "ndjson", "yaml", "csv", "tsv", "markdown", "pdf", "cpuprofile", "strace", "source", "git"], + "description": "Force input format instead of auto-detecting" + }, + "semantic_paths": { + "type": "boolean", + "default": false, + "description": "Include semantic labels on tree-sitter nodes (source code input)" + }, + "lang": { + "type": "string", + "description": "Source language (rust, python, go, javascript, etc.)" + } + }, + "required": ["path"] + }) + } + + fn annotations(&self) -> Option { + Some(json!({ + "title": "Inspect", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + })) + } + + fn call(&self, params: Value) -> Result { + let path = params["path"] + .as_str() + .ok_or_else(|| ToolError::InvalidParams("path is required".into()))?; + + let mut cmd = Command::new(vajra_bin()); + cmd.arg("inspect").arg(path).arg("--quiet"); + + let fmt = params["format"].as_str().unwrap_or("json"); + cmd.arg("--format").arg(fmt); + + if let Some(input_fmt) = params["input_format"].as_str() { + cmd.arg("--input-format").arg(input_fmt); + } + if params["semantic_paths"].as_bool().unwrap_or(false) { + cmd.arg("--semantic-paths"); + } + if let Some(lang) = params["lang"].as_str() { + cmd.arg("--lang").arg(lang); + } + + run_vajra(&mut cmd) + } +} + +// ───────────────────────────────────────────────── +// vajra_stats +// ───────────────────────────────────────────────── + +pub struct StatsTool; + +impl Tool for StatsTool { + fn name(&self) -> &'static str { + "vajra_stats" + } + + fn description(&self) -> &'static str { + "Statistical summary of a data file — distributions, cardinalities, \ + numeric ranges, temporal patterns, and optional time-series windowing." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to data file" + }, + "format": { + "type": "string", + "enum": ["json", "text", "markdown", "compact-ai"], + "default": "json" + }, + "window": { + "type": "string", + "enum": ["month", "week", "day"], + "description": "Time-series window granularity" + }, + "time_field": { + "type": "string", + "description": "JSONPath to the timestamp field (e.g. '$.date')" + } + }, + "required": ["path"] + }) + } + + fn annotations(&self) -> Option { + Some(json!({ + "title": "Stats", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + })) + } + + fn call(&self, params: Value) -> Result { + let path = params["path"] + .as_str() + .ok_or_else(|| ToolError::InvalidParams("path is required".into()))?; + + let mut cmd = Command::new(vajra_bin()); + cmd.arg("stats").arg(path).arg("--quiet"); + + let fmt = params["format"].as_str().unwrap_or("json"); + cmd.arg("--format").arg(fmt); + + if let Some(window) = params["window"].as_str() { + cmd.arg("--window").arg(window); + } + if let Some(tf) = params["time_field"].as_str() { + cmd.arg("--time-field").arg(tf); + } + + run_vajra(&mut cmd) + } +} + +// ───────────────────────────────────────────────── +// vajra_anomalies +// ───────────────────────────────────────────────── + +pub struct AnomaliesTool; + +impl Tool for AnomaliesTool { + fn name(&self) -> &'static str { + "vajra_anomalies" + } + + fn description(&self) -> &'static str { + "Anomaly detection — finds structural outliers, type inconsistencies, \ + unusual distributions, and suspicious patterns in data files." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to data file" + }, + "format": { + "type": "string", + "enum": ["json", "text", "markdown", "compact-ai"], + "default": "json" + } + }, + "required": ["path"] + }) + } + + fn annotations(&self) -> Option { + Some(json!({ + "title": "Anomalies", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + })) + } + + fn call(&self, params: Value) -> Result { + let path = params["path"] + .as_str() + .ok_or_else(|| ToolError::InvalidParams("path is required".into()))?; + + let mut cmd = Command::new(vajra_bin()); + cmd.arg("anomalies").arg(path).arg("--quiet"); + + let fmt = params["format"].as_str().unwrap_or("json"); + cmd.arg("--format").arg(fmt); + + run_vajra(&mut cmd) + } +} + +// ───────────────────────────────────────────────── +// vajra_fingerprint +// ───────────────────────────────────────────────── + +pub struct FingerprintTool; + +impl Tool for FingerprintTool { + fn name(&self) -> &'static str { + "vajra_fingerprint" + } + + fn description(&self) -> &'static str { + "Structural fingerprints — BLAKE3 content hashes, schema hashes, and \ + similarity hashes for comparing data files across versions." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to data file" + }, + "format": { + "type": "string", + "enum": ["json", "text", "markdown", "compact-ai"], + "default": "json" + } + }, + "required": ["path"] + }) + } + + fn annotations(&self) -> Option { + Some(json!({ + "title": "Fingerprint", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + })) + } + + fn call(&self, params: Value) -> Result { + let path = params["path"] + .as_str() + .ok_or_else(|| ToolError::InvalidParams("path is required".into()))?; + + let mut cmd = Command::new(vajra_bin()); + cmd.arg("fingerprint").arg(path).arg("--quiet"); + + let fmt = params["format"].as_str().unwrap_or("json"); + cmd.arg("--format").arg(fmt); + + run_vajra(&mut cmd) + } +} + +// ───────────────────────────────────────────────── +// vajra_essence +// ───────────────────────────────────────────────── + +pub struct EssenceTool; + +impl Tool for EssenceTool { + fn name(&self) -> &'static str { + "vajra_essence" + } + + fn description(&self) -> &'static str { + "Generate concern-oriented essence — a compressed, priority-ranked \ + summary of a data file tailored to a specific profile (engineer, \ + security, executive, etc.) with optional token budget." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to data file" + }, + "profile": { + "type": "string", + "description": "Concern profile (engineer, security, executive, etc.)", + "default": "engineer" + }, + "format": { + "type": "string", + "enum": ["json", "text", "markdown", "compact-ai"], + "default": "json" + }, + "budget": { + "type": "integer", + "description": "Approximate max token budget for output" + } + }, + "required": ["path"] + }) + } + + fn annotations(&self) -> Option { + Some(json!({ + "title": "Essence", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + })) + } + + fn call(&self, params: Value) -> Result { + let path = params["path"] + .as_str() + .ok_or_else(|| ToolError::InvalidParams("path is required".into()))?; + + let mut cmd = Command::new(vajra_bin()); + cmd.arg("essence").arg(path).arg("--quiet"); + + if let Some(profile) = params["profile"].as_str() { + cmd.arg("--profile").arg(profile); + } + + let fmt = params["format"].as_str().unwrap_or("json"); + cmd.arg("--format").arg(fmt); + + if let Some(budget) = params["budget"].as_u64() { + cmd.arg("--budget").arg(budget.to_string()); + } + + run_vajra(&mut cmd) + } +} + +// ───────────────────────────────────────────────── +// vajra_invariants +// ───────────────────────────────────────────────── + +pub struct InvariantsTool; + +impl Tool for InvariantsTool { + fn name(&self) -> &'static str { + "vajra_invariants" + } + + fn description(&self) -> &'static str { + "Discover cross-field relationships — correlations, functional \ + dependencies, and invariants across fields in a dataset." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to data file" + }, + "format": { + "type": "string", + "enum": ["json", "text", "markdown", "compact-ai"], + "default": "json" + }, + "top_k": { + "type": "integer", + "description": "Maximum number of field pairs to consider", + "default": 50 + } + }, + "required": ["path"] + }) + } + + fn annotations(&self) -> Option { + Some(json!({ + "title": "Invariants", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + })) + } + + fn call(&self, params: Value) -> Result { + let path = params["path"] + .as_str() + .ok_or_else(|| ToolError::InvalidParams("path is required".into()))?; + + let mut cmd = Command::new(vajra_bin()); + cmd.arg("invariants").arg(path).arg("--quiet"); + + let fmt = params["format"].as_str().unwrap_or("json"); + cmd.arg("--format").arg(fmt); + + if let Some(top_k) = params["top_k"].as_u64() { + cmd.arg("--top-k").arg(top_k.to_string()); + } + + run_vajra(&mut cmd) + } +} + +// ───────────────────────────────────────────────── +// vajra_query +// ───────────────────────────────────────────────── + +pub struct QueryTool; + +impl Tool for QueryTool { + fn name(&self) -> &'static str { + "vajra_query" + } + + fn description(&self) -> &'static str { + "Run a query expression against a data file. Supports entropy, \ + cardinality, distribution, and statistical predicates \ + (e.g. 'entropy($.claims[*].status) > 0.5')." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to data file" + }, + "expression": { + "type": "string", + "description": "Query expression (e.g. 'entropy($.claims[*].status) > 0.5')" + } + }, + "required": ["path", "expression"] + }) + } + + fn annotations(&self) -> Option { + Some(json!({ + "title": "Query", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + })) + } + + fn call(&self, params: Value) -> Result { + let path = params["path"] + .as_str() + .ok_or_else(|| ToolError::InvalidParams("path is required".into()))?; + let expression = params["expression"] + .as_str() + .ok_or_else(|| ToolError::InvalidParams("expression is required".into()))?; + + let mut cmd = Command::new(vajra_bin()); + cmd.arg("query").arg(path).arg(expression).arg("--quiet"); + + run_vajra(&mut cmd) + } +} + +// ───────────────────────────────────────────────── +// vajra_drift +// ───────────────────────────────────────────────── + +pub struct DriftTool; + +impl Tool for DriftTool { + fn name(&self) -> &'static str { + "vajra_drift" + } + + fn description(&self) -> &'static str { + "Detect drift between two data files — structural changes, schema \ + evolution, value distribution shifts. Supports population-level \ + drift with --group-by." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "baseline": { + "type": "string", + "description": "Baseline file path" + }, + "candidate": { + "type": "string", + "description": "Candidate file path to compare" + }, + "format": { + "type": "string", + "enum": ["json", "text", "markdown", "compact-ai"], + "default": "json" + }, + "group_by": { + "type": "string", + "description": "JSONPath field to partition records by for population-level drift" + } + }, + "required": ["baseline"] + }) + } + + fn annotations(&self) -> Option { + Some(json!({ + "title": "Drift", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + })) + } + + fn call(&self, params: Value) -> Result { + let baseline = params["baseline"] + .as_str() + .ok_or_else(|| ToolError::InvalidParams("baseline is required".into()))?; + + let mut cmd = Command::new(vajra_bin()); + cmd.arg("drift").arg(baseline); + + if let Some(candidate) = params["candidate"].as_str() { + cmd.arg(candidate); + } + + cmd.arg("--quiet"); + + let fmt = params["format"].as_str().unwrap_or("json"); + cmd.arg("--format").arg(fmt); + + if let Some(group_by) = params["group_by"].as_str() { + cmd.arg("--group-by").arg(group_by); + } + + run_vajra(&mut cmd) + } +} + +// ───────────────────────────────────────────────── +// vajra_cascade +// ───────────────────────────────────────────────── + +pub struct CascadeTool; + +impl Tool for CascadeTool { + fn name(&self) -> &'static str { + "vajra_cascade" + } + + fn description(&self) -> &'static str { + "Detect temporal cause-effect chains in event data — finds cascading \ + failures, trigger-response patterns, and causal sequences." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to data file" + }, + "entity_field": { + "type": "string", + "description": "Field identifying the entity (default: file)", + "default": "file" + }, + "time_field": { + "type": "string", + "description": "Field identifying the timestamp (default: date)", + "default": "date" + }, + "event_field": { + "type": "string", + "description": "Field identifying the event type (default: intent)", + "default": "intent" + }, + "response_values": { + "type": "string", + "description": "Comma-separated response event values (default: fix,revert)", + "default": "fix,revert" + } + }, + "required": ["path", "entity_field", "time_field", "event_field"] + }) + } + + fn annotations(&self) -> Option { + Some(json!({ + "title": "Cascade", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + })) + } + + fn call(&self, params: Value) -> Result { + let path = params["path"] + .as_str() + .ok_or_else(|| ToolError::InvalidParams("path is required".into()))?; + let entity_field = params["entity_field"].as_str().unwrap_or("file"); + let time_field = params["time_field"].as_str().unwrap_or("date"); + let event_field = params["event_field"].as_str().unwrap_or("intent"); + + let mut cmd = Command::new(vajra_bin()); + cmd.arg("cascade") + .arg(path) + .arg("--entity-field") + .arg(entity_field) + .arg("--time-field") + .arg(time_field) + .arg("--event-field") + .arg(event_field) + .arg("--quiet"); + + if let Some(rv) = params["response_values"].as_str() { + cmd.arg("--response-values").arg(rv); + } + + cmd.arg("--format").arg("json"); + + run_vajra(&mut cmd) + } +} + +// ───────────────────────────────────────────────── +// vajra_batch +// ───────────────────────────────────────────────── + +pub struct BatchTool; + +impl Tool for BatchTool { + fn name(&self) -> &'static str { + "vajra_batch" + } + + fn description(&self) -> &'static str { + "Parallel batch analysis of all data files in a directory — runs \ + inspect + stats + anomalies across every file and returns aggregated results." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "directory": { + "type": "string", + "description": "Directory containing data files to analyze" + }, + "format": { + "type": "string", + "enum": ["json", "text", "markdown", "compact-ai"], + "default": "json" + } + }, + "required": ["directory"] + }) + } + + fn annotations(&self) -> Option { + Some(json!({ + "title": "Batch", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + })) + } + + fn call(&self, params: Value) -> Result { + let directory = params["directory"] + .as_str() + .ok_or_else(|| ToolError::InvalidParams("directory is required".into()))?; + + let mut cmd = Command::new(vajra_bin()); + cmd.arg("batch").arg(directory).arg("--quiet"); + + let fmt = params["format"].as_str().unwrap_or("json"); + cmd.arg("--format").arg(fmt); + + run_vajra(&mut cmd) + } +} + +// ───────────────────────────────────────────────── +// vajra_cluster +// ───────────────────────────────────────────────── + +pub struct ClusterTool; + +impl Tool for ClusterTool { + fn name(&self) -> &'static str { + "vajra_cluster" + } + + fn description(&self) -> &'static str { + "Cluster similar documents in a batch — groups files by structural \ + similarity using fingerprint-based distance metrics." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "paths": { + "type": "array", + "items": { "type": "string" }, + "description": "Array of file paths to cluster" + }, + "format": { + "type": "string", + "enum": ["json", "text", "markdown", "compact-ai"], + "default": "json" + } + }, + "required": ["paths"] + }) + } + + fn annotations(&self) -> Option { + Some(json!({ + "title": "Cluster", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + })) + } + + fn call(&self, params: Value) -> Result { + let paths = params["paths"] + .as_array() + .ok_or_else(|| ToolError::InvalidParams("paths array is required".into()))?; + + if paths.is_empty() { + return Err(ToolError::InvalidParams( + "paths must contain at least one file".into(), + )); + } + + let mut cmd = Command::new(vajra_bin()); + cmd.arg("cluster"); + + for p in paths { + if let Some(s) = p.as_str() { + cmd.arg(s); + } + } + + cmd.arg("--quiet"); + + let fmt = params["format"].as_str().unwrap_or("json"); + cmd.arg("--format").arg(fmt); + + run_vajra(&mut cmd) + } +} + +// ───────────────────────────────────────────────── +// vajra_profiles +// ───────────────────────────────────────────────── + +pub struct ProfilesTool; + +impl Tool for ProfilesTool { + fn name(&self) -> &'static str { + "vajra_profiles" + } + + fn description(&self) -> &'static str { + "List all available concern profiles (built-in and custom) for \ + the essence command." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }) + } + + fn annotations(&self) -> Option { + Some(json!({ + "title": "Profiles", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + })) + } + + fn call(&self, _params: Value) -> Result { + let mut cmd = Command::new(vajra_bin()); + cmd.arg("profiles") + .arg("--quiet") + .arg("--format") + .arg("json"); + + run_vajra(&mut cmd) + } +} diff --git a/vajra-mcp/src/tools/compare.rs b/vajra-mcp/src/tools/compare.rs new file mode 100644 index 0000000..3380ab2 --- /dev/null +++ b/vajra-mcp/src/tools/compare.rs @@ -0,0 +1,99 @@ +//! Compare tools — cross-repo comparison and benchmarking. + +use std::process::Command; + +use serde_json::{json, Value}; + +use super::{run_vajra, vajra_bin, CallToolResult, Tool, ToolError}; + +// ───────────────────────────────────────────────── +// vajra_compare +// ───────────────────────────────────────────────── + +pub struct CompareTool; + +impl Tool for CompareTool { + fn name(&self) -> &'static str { + "vajra_compare" + } + + fn description(&self) -> &'static str { + "Cross-repo comparison — multi-project benchmarking across governance, \ + activity, code quality, and community metrics. Requires two or more \ + data files to compare." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "paths": { + "type": "array", + "items": { "type": "string" }, + "minItems": 2, + "description": "Two or more file paths to compare" + }, + "labels": { + "type": "string", + "description": "Comma-separated labels for each dataset (default: filenames)" + }, + "author_field": { + "type": "string", + "description": "JSONPath to the author field", + "default": "$.author" + }, + "format": { + "type": "string", + "enum": ["json", "text", "markdown", "compact-ai"], + "default": "json" + } + }, + "required": ["paths"] + }) + } + + fn annotations(&self) -> Option { + Some(json!({ + "title": "Compare", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + })) + } + + fn call(&self, params: Value) -> Result { + let paths = params["paths"] + .as_array() + .ok_or_else(|| ToolError::InvalidParams("paths array is required".into()))?; + + if paths.len() < 2 { + return Err(ToolError::InvalidParams( + "paths must contain at least two files".into(), + )); + } + + let mut cmd = Command::new(vajra_bin()); + cmd.arg("compare"); + + for p in paths { + if let Some(s) = p.as_str() { + cmd.arg(s); + } + } + + cmd.arg("--quiet"); + + if let Some(labels) = params["labels"].as_str() { + cmd.arg("--labels").arg(labels); + } + if let Some(af) = params["author_field"].as_str() { + cmd.arg("--author-field").arg(af); + } + + let fmt = params["format"].as_str().unwrap_or("json"); + cmd.arg("--format").arg(fmt); + + run_vajra(&mut cmd) + } +} diff --git a/vajra-mcp/src/tools/governance.rs b/vajra-mcp/src/tools/governance.rs new file mode 100644 index 0000000..055abce --- /dev/null +++ b/vajra-mcp/src/tools/governance.rs @@ -0,0 +1,226 @@ +//! Governance tools — read-only analysis of contributor patterns and project health. + +use std::process::Command; + +use serde_json::{json, Value}; + +use super::{run_vajra, vajra_bin, CallToolResult, Tool, ToolError}; + +// ───────────────────────────────────────────────── +// vajra_governance +// ───────────────────────────────────────────────── + +pub struct GovernanceTool; + +impl Tool for GovernanceTool { + fn name(&self) -> &'static str { + "vajra_governance" + } + + fn description(&self) -> &'static str { + "Governance metrics — bus factor, merge equity, contributor churn, \ + and concentration analysis for commit/PR data." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to data file (commits, PRs, etc.)" + }, + "author_field": { + "type": "string", + "description": "JSONPath to the author field (e.g. '$.author')", + "default": "$.author" + }, + "time_field": { + "type": "string", + "description": "JSONPath to the timestamp field (e.g. '$.date')", + "default": "$.date" + }, + "format": { + "type": "string", + "enum": ["json", "text", "markdown", "compact-ai"], + "default": "json" + } + }, + "required": ["path"] + }) + } + + fn annotations(&self) -> Option { + Some(json!({ + "title": "Governance", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + })) + } + + fn call(&self, params: Value) -> Result { + let path = params["path"] + .as_str() + .ok_or_else(|| ToolError::InvalidParams("path is required".into()))?; + + let mut cmd = Command::new(vajra_bin()); + cmd.arg("governance").arg(path).arg("--quiet"); + + if let Some(af) = params["author_field"].as_str() { + cmd.arg("--author-field").arg(af); + } + if let Some(tf) = params["time_field"].as_str() { + cmd.arg("--time-field").arg(tf); + } + + let fmt = params["format"].as_str().unwrap_or("json"); + cmd.arg("--format").arg(fmt); + + run_vajra(&mut cmd) + } +} + +// ───────────────────────────────────────────────── +// vajra_core_team +// ───────────────────────────────────────────────── + +pub struct CoreTeamTool; + +impl Tool for CoreTeamTool { + fn name(&self) -> &'static str { + "vajra_core_team" + } + + fn description(&self) -> &'static str { + "Detect core team members from commit patterns — identifies sustained \ + contributors, their specializations, and contribution patterns." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to commit data file" + }, + "format": { + "type": "string", + "enum": ["json", "text", "markdown", "compact-ai"], + "default": "json" + } + }, + "required": ["path"] + }) + } + + fn annotations(&self) -> Option { + Some(json!({ + "title": "Core Team", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + })) + } + + fn call(&self, params: Value) -> Result { + let path = params["path"] + .as_str() + .ok_or_else(|| ToolError::InvalidParams("path is required".into()))?; + + let mut cmd = Command::new(vajra_bin()); + cmd.arg("core-team").arg(path).arg("--quiet"); + + let fmt = params["format"].as_str().unwrap_or("json"); + cmd.arg("--format").arg(fmt); + + run_vajra(&mut cmd) + } +} + +// ───────────────────────────────────────────────── +// vajra_score +// ───────────────────────────────────────────────── + +pub struct ScoreTool; + +impl Tool for ScoreTool { + fn name(&self) -> &'static str { + "vajra_score" + } + + fn description(&self) -> &'static str { + "Automated health scoring with letter grades — computes composite \ + scores across activity, governance, code quality, and community dimensions." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to data file" + }, + "author_field": { + "type": "string", + "description": "JSONPath to the author field", + "default": "$.author" + }, + "time_field": { + "type": "string", + "description": "JSONPath to the timestamp field", + "default": "$.date" + }, + "message_field": { + "type": "string", + "description": "JSONPath to the commit message field", + "default": "$.message" + }, + "format": { + "type": "string", + "enum": ["json", "text", "markdown", "compact-ai"], + "default": "json" + } + }, + "required": ["path"] + }) + } + + fn annotations(&self) -> Option { + Some(json!({ + "title": "Score", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + })) + } + + fn call(&self, params: Value) -> Result { + let path = params["path"] + .as_str() + .ok_or_else(|| ToolError::InvalidParams("path is required".into()))?; + + let mut cmd = Command::new(vajra_bin()); + cmd.arg("score").arg(path).arg("--quiet"); + + if let Some(af) = params["author_field"].as_str() { + cmd.arg("--author-field").arg(af); + } + if let Some(tf) = params["time_field"].as_str() { + cmd.arg("--time-field").arg(tf); + } + if let Some(mf) = params["message_field"].as_str() { + cmd.arg("--message-field").arg(mf); + } + + let fmt = params["format"].as_str().unwrap_or("json"); + cmd.arg("--format").arg(fmt); + + run_vajra(&mut cmd) + } +} diff --git a/vajra-mcp/src/tools/mod.rs b/vajra-mcp/src/tools/mod.rs new file mode 100644 index 0000000..e367f24 --- /dev/null +++ b/vajra-mcp/src/tools/mod.rs @@ -0,0 +1,160 @@ +//! Tool trait, registry, and shared helpers. +//! +//! Every MCP tool implements the `Tool` trait. The `ToolRegistry` collects +//! them and provides lookup by name for the router. + +pub mod analyze; +pub mod compare; +pub mod governance; +pub mod pipeline; + +use std::collections::HashMap; +use std::fmt; +use std::process::Command; + +use serde_json::Value; + +use crate::protocol::{CallToolResult, ToolDescriptor}; + +// ───────────────────────────────────────────────── +// Tool trait +// ───────────────────────────────────────────────── + +pub trait Tool: Send + Sync { + fn name(&self) -> &'static str; + fn description(&self) -> &'static str; + fn input_schema(&self) -> Value; + fn annotations(&self) -> Option { + None + } + fn call(&self, params: Value) -> Result; +} + +// ───────────────────────────────────────────────── +// Tool errors +// ───────────────────────────────────────────────── + +#[derive(Debug)] +#[allow(dead_code)] +pub enum ToolError { + InvalidParams(String), + Execution(String), + Internal(String), +} + +impl fmt::Display for ToolError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidParams(msg) => write!(f, "Invalid params: {msg}"), + Self::Execution(msg) => write!(f, "Execution error: {msg}"), + Self::Internal(msg) => write!(f, "Internal error: {msg}"), + } + } +} + +// ───────────────────────────────────────────────── +// Registry +// ───────────────────────────────────────────────── + +pub struct ToolRegistry { + tools: HashMap>, + order: Vec, +} + +impl ToolRegistry { + pub fn new() -> Self { + Self { + tools: HashMap::new(), + order: Vec::new(), + } + } + + pub fn register(&mut self, tool: Box) { + let name = tool.name().to_string(); + self.order.push(name.clone()); + self.tools.insert(name, tool); + } + + pub fn get(&self, name: &str) -> Option<&dyn Tool> { + self.tools.get(name).map(|t| t.as_ref()) + } + + pub fn descriptors(&self) -> Vec { + self.order + .iter() + .filter_map(|name| self.tools.get(name)) + .map(|t| ToolDescriptor { + name: t.name().to_string(), + description: t.description().to_string(), + input_schema: t.input_schema(), + annotations: t.annotations(), + }) + .collect() + } +} + +// ───────────────────────────────────────────────── +// Register all tools +// ───────────────────────────────────────────────── + +pub fn register_all() -> ToolRegistry { + let mut reg = ToolRegistry::new(); + + // Analysis tools (read-only, idempotent) + reg.register(Box::new(analyze::InspectTool)); + reg.register(Box::new(analyze::StatsTool)); + reg.register(Box::new(analyze::AnomaliesTool)); + reg.register(Box::new(analyze::FingerprintTool)); + reg.register(Box::new(analyze::EssenceTool)); + reg.register(Box::new(analyze::InvariantsTool)); + reg.register(Box::new(analyze::QueryTool)); + reg.register(Box::new(analyze::DriftTool)); + reg.register(Box::new(analyze::CascadeTool)); + reg.register(Box::new(analyze::BatchTool)); + reg.register(Box::new(analyze::ClusterTool)); + + // Governance tools (read-only, idempotent) + reg.register(Box::new(governance::GovernanceTool)); + reg.register(Box::new(governance::CoreTeamTool)); + reg.register(Box::new(governance::ScoreTool)); + + // Compare tools (read-only, idempotent) + reg.register(Box::new(compare::CompareTool)); + + // Pipeline tools (may modify filesystem) + reg.register(Box::new(pipeline::IngestGithubTool)); + reg.register(Box::new(pipeline::ReportTool)); + reg.register(Box::new(pipeline::AuditTool)); + + // Meta + reg.register(Box::new(analyze::ProfilesTool)); + + reg +} + +// ───────────────────────────────────────────────── +// Shared helpers +// ───────────────────────────────────────────────── + +/// Run a vajra CLI command and return a `CallToolResult`. +pub fn run_vajra(cmd: &mut Command) -> Result { + let output = cmd + .output() + .map_err(|e| ToolError::Execution(format!("failed to execute vajra: {e}")))?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + if output.status.success() { + Ok(CallToolResult::text(stdout)) + } else { + let msg = if stderr.is_empty() { stdout } else { stderr }; + Ok(CallToolResult::error(msg)) + } +} + +/// Resolve the vajra binary name. Checks `VAJRA_BIN` env var first, +/// then falls back to `"vajra"` on PATH. +pub fn vajra_bin() -> String { + std::env::var("VAJRA_BIN").unwrap_or_else(|_| "vajra".into()) +} diff --git a/vajra-mcp/src/tools/pipeline.rs b/vajra-mcp/src/tools/pipeline.rs new file mode 100644 index 0000000..e34988d --- /dev/null +++ b/vajra-mcp/src/tools/pipeline.rs @@ -0,0 +1,253 @@ +//! Pipeline tools — may modify filesystem (ingest, report generation, audit). + +use std::process::Command; + +use serde_json::{json, Value}; + +use super::{run_vajra, vajra_bin, CallToolResult, Tool, ToolError}; + +// ───────────────────────────────────────────────── +// vajra_ingest_github +// ───────────────────────────────────────────────── + +pub struct IngestGithubTool; + +impl Tool for IngestGithubTool { + fn name(&self) -> &'static str { + "vajra_ingest_github" + } + + fn description(&self) -> &'static str { + "Ingest GitHub repository data (PRs, issues, commits, releases) via \ + the gh CLI. Writes JSON files to the output directory for downstream \ + analysis. Requires gh CLI to be authenticated." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "repo": { + "type": "string", + "description": "Owner/repo identifier (e.g. 'facebook/react')" + }, + "output": { + "type": "string", + "description": "Output directory for ingested JSON files (default: current dir)" + }, + "pr_limit": { + "type": "integer", + "description": "Maximum number of pull requests to fetch", + "default": 500 + }, + "issue_limit": { + "type": "integer", + "description": "Maximum number of issues to fetch", + "default": 500 + }, + "commit_limit": { + "type": "integer", + "description": "Maximum number of commits to fetch", + "default": 800 + } + }, + "required": ["repo"] + }) + } + + fn annotations(&self) -> Option { + Some(json!({ + "title": "Ingest GitHub", + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + })) + } + + fn call(&self, params: Value) -> Result { + let repo = params["repo"] + .as_str() + .ok_or_else(|| ToolError::InvalidParams("repo is required".into()))?; + + let mut cmd = Command::new(vajra_bin()); + cmd.arg("ingest-github").arg(repo).arg("--quiet"); + + if let Some(output) = params["output"].as_str() { + cmd.arg("--output").arg(output); + } + if let Some(pr_limit) = params["pr_limit"].as_u64() { + cmd.arg("--pr-limit").arg(pr_limit.to_string()); + } + if let Some(issue_limit) = params["issue_limit"].as_u64() { + cmd.arg("--issue-limit").arg(issue_limit.to_string()); + } + if let Some(commit_limit) = params["commit_limit"].as_u64() { + cmd.arg("--commit-limit").arg(commit_limit.to_string()); + } + + run_vajra(&mut cmd) + } +} + +// ───────────────────────────────────────────────── +// vajra_report +// ───────────────────────────────────────────────── + +pub struct ReportTool; + +impl Tool for ReportTool { + fn name(&self) -> &'static str { + "vajra_report" + } + + fn description(&self) -> &'static str { + "Generate an HTML analysis report from pre-computed JSON files \ + (stats.json, anomalies.json, etc.). Produces a self-contained \ + HTML file with charts and tables." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "input_dir": { + "type": "string", + "description": "Directory containing analysis JSON files" + }, + "title": { + "type": "string", + "description": "Report title", + "default": "Analysis Report" + }, + "output": { + "type": "string", + "description": "Output HTML file path", + "default": "report.html" + }, + "repo_name": { + "type": "string", + "description": "Repository name (e.g. 'facebook/react')" + } + }, + "required": ["input_dir", "title", "output"] + }) + } + + fn annotations(&self) -> Option { + Some(json!({ + "title": "Report", + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + })) + } + + fn call(&self, params: Value) -> Result { + let input_dir = params["input_dir"] + .as_str() + .ok_or_else(|| ToolError::InvalidParams("input_dir is required".into()))?; + let title = params["title"].as_str().unwrap_or("Analysis Report"); + let output = params["output"].as_str().unwrap_or("report.html"); + + let mut cmd = Command::new(vajra_bin()); + cmd.arg("report") + .arg(input_dir) + .arg("--title") + .arg(title) + .arg("--output") + .arg(output) + .arg("--quiet"); + + if let Some(repo_name) = params["repo_name"].as_str() { + cmd.arg("--repo-name").arg(repo_name); + } + + run_vajra(&mut cmd) + } +} + +// ───────────────────────────────────────────────── +// vajra_audit +// ───────────────────────────────────────────────── + +pub struct AuditTool; + +impl Tool for AuditTool { + fn name(&self) -> &'static str { + "vajra_audit" + } + + fn description(&self) -> &'static str { + "One-command audit: ingest GitHub data, run full analysis, and \ + generate an HTML report. Requires gh CLI authentication. \ + Produces a comprehensive project health report." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "repo": { + "type": "string", + "description": "Repository URL or owner/repo (e.g. 'owner/repo')" + }, + "output": { + "type": "string", + "description": "Output HTML report path (default: '{owner}-{repo}-report.html')" + }, + "commit_limit": { + "type": "integer", + "description": "Maximum number of commits to fetch", + "default": 800 + }, + "pr_limit": { + "type": "integer", + "description": "Maximum number of pull requests to fetch", + "default": 500 + }, + "issue_limit": { + "type": "integer", + "description": "Maximum number of issues to fetch", + "default": 500 + } + }, + "required": ["repo"] + }) + } + + fn annotations(&self) -> Option { + Some(json!({ + "title": "Audit", + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + })) + } + + fn call(&self, params: Value) -> Result { + let repo = params["repo"] + .as_str() + .ok_or_else(|| ToolError::InvalidParams("repo is required".into()))?; + + let mut cmd = Command::new(vajra_bin()); + cmd.arg("audit").arg(repo).arg("--quiet"); + + if let Some(output) = params["output"].as_str() { + cmd.arg("--output").arg(output); + } + if let Some(commit_limit) = params["commit_limit"].as_u64() { + cmd.arg("--commit-limit").arg(commit_limit.to_string()); + } + if let Some(pr_limit) = params["pr_limit"].as_u64() { + cmd.arg("--pr-limit").arg(pr_limit.to_string()); + } + if let Some(issue_limit) = params["issue_limit"].as_u64() { + cmd.arg("--issue-limit").arg(issue_limit.to_string()); + } + + run_vajra(&mut cmd) + } +} diff --git a/vajra-mcp/src/transport.rs b/vajra-mcp/src/transport.rs new file mode 100644 index 0000000..13daaa7 --- /dev/null +++ b/vajra-mcp/src/transport.rs @@ -0,0 +1,76 @@ +//! Stdio transport — line-delimited JSON-RPC over stdin/stdout. +//! +//! Each message is a single JSON line. Blank lines are ignored. + +use std::io::{self, BufRead, Write}; + +use crate::protocol::{JsonRpcError, JsonRpcMessage}; +use crate::router::Router; + +/// Run the MCP server loop, reading JSON-RPC from stdin and writing to stdout. +pub fn run_stdio(router: Router) { + let stdin = io::stdin(); + let mut stdout = io::stdout(); + let reader = stdin.lock(); + + for line_result in reader.lines() { + let line = match line_result { + Ok(l) => l, + Err(e) => { + let _ = write_parse_error(&mut stdout, &e.to_string()); + continue; + } + }; + + let trimmed = line.trim().to_string(); + if trimmed.is_empty() { + continue; + } + + let msg: JsonRpcMessage = match serde_json::from_str(&trimmed) { + Ok(m) => m, + Err(e) => { + let err_resp = serde_json::json!({ + "jsonrpc": "2.0", + "id": null, + "error": { + "code": JsonRpcError::parse_error(&e.to_string()).code, + "message": format!("Parse error: {e}") + } + }); + let _ = write_json(&mut stdout, &err_resp); + continue; + } + }; + + if let Some(response) = router.handle(msg) { + match serde_json::to_value(&response) { + Ok(val) => { + let _ = write_json(&mut stdout, &val); + } + Err(e) => { + let _ = write_parse_error(&mut stdout, &e.to_string()); + } + } + } + } +} + +fn write_json(stdout: &mut io::Stdout, value: &serde_json::Value) -> io::Result<()> { + let s = serde_json::to_string(value).unwrap_or_else(|_| "{}".into()); + stdout.write_all(s.as_bytes())?; + stdout.write_all(b"\n")?; + stdout.flush() +} + +fn write_parse_error(stdout: &mut io::Stdout, detail: &str) -> io::Result<()> { + let err = serde_json::json!({ + "jsonrpc": "2.0", + "id": null, + "error": { + "code": -32700, + "message": format!("Parse error: {detail}") + } + }); + write_json(stdout, &err) +}