use std::io::{self, BufRead, Write};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use clap::Parser;
use impactsense_parser::pipeline::ScanOptions;
use impactsense_parser::project::parse_project;
use impactsense_parser::store::{GraphStore, InMemoryGraph, QueryLimits};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tokio::sync::RwLock;
use tracing::info;
mod watcher;
#[derive(Parser, Debug)]
#[command(name = "impactsense-mcp")]
struct Args {
#[arg(long, env = "IMPACTSENSE_ROOT")]
root: PathBuf,
#[arg(long, default_value_t = 300)]
debounce_ms: u64,
}
#[derive(Debug, Deserialize)]
struct JsonRpcRequest {
#[serde(rename = "jsonrpc")]
_jsonrpc: Option<String>,
id: Option<Value>,
method: String,
params: Option<Value>,
}
#[derive(Debug, Serialize)]
struct JsonRpcResponse {
jsonrpc: &'static str,
id: Value,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<JsonRpcError>,
}
#[derive(Debug, Serialize)]
struct JsonRpcError {
code: i32,
message: String,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_writer(std::io::stderr)
.init();
let args = Args::parse();
let root = args.root.canonicalize().context("invalid workspace root")?;
info!(root = %root.display(), "bootstrap parsing workspace");
let scan = ScanOptions::default();
let graph = Arc::new(RwLock::new(parse_project(&root, &scan)?));
info!(
nodes = graph.read().await.node_count(),
edges = graph.read().await.edge_count(),
"graph ready"
);
watcher::spawn_file_watcher(
root.clone(),
graph.clone(),
scan.clone(),
Duration::from_millis(args.debounce_ms),
)?;
run_mcp_stdio(graph).await
}
async fn run_mcp_stdio(graph: Arc<RwLock<InMemoryGraph>>) -> Result<()> {
let stdin = io::stdin();
let mut stdout = io::stdout();
let tools = tool_definitions();
for line in stdin.lock().lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let req: JsonRpcRequest = match serde_json::from_str(&line) {
Ok(v) => v,
Err(e) => {
write_error(&mut stdout, Value::Null, -32700, e.to_string())?;
continue;
}
};
let id = req.id.clone().unwrap_or(Value::Null);
let result = match req.method.as_str() {
"initialize" => json!({
"protocolVersion": "2024-11-05",
"capabilities": { "tools": {} },
"serverInfo": { "name": "impactsense-mcp", "version": env!("CARGO_PKG_VERSION") }
}),
"notifications/initialized" | "initialized" => continue,
"tools/list" => json!({ "tools": tools }),
"tools/call" => handle_tool_call(&graph, req.params).await,
"ping" => json!({}),
_ => {
write_error(
&mut stdout,
id,
-32601,
format!("method not found: {}", req.method),
)?;
continue;
}
};
let resp = JsonRpcResponse {
jsonrpc: "2.0",
id,
result: Some(result),
error: None,
};
serde_json::to_writer(&mut stdout, &resp)?;
stdout.write_all(b"\n")?;
stdout.flush()?;
}
Ok(())
}
fn tool_definitions() -> Vec<Value> {
vec![
tool_def("find_symbol", "Search symbols by name or FQN substring", json!({
"type": "object",
"properties": { "query": { "type": "string" } },
"required": ["query"]
})),
tool_def("callers", "Direct callers of a function FQN", json!({
"type": "object",
"properties": { "fqn": { "type": "string" } },
"required": ["fqn"]
})),
tool_def("callees", "Direct callees of a function FQN", json!({
"type": "object",
"properties": { "fqn": { "type": "string" } },
"required": ["fqn"]
})),
tool_def("file_dependencies", "File import dependencies for a repo-relative path", json!({
"type": "object",
"properties": { "path": { "type": "string" } },
"required": ["path"]
})),
tool_def("symbols_in_file", "Classes/functions/modules/endpoints declared in a file", json!({
"type": "object",
"properties": { "path": { "type": "string" } },
"required": ["path"]
})),
tool_def("impact_analysis", "Transitive callers up to depth (default 2)", json!({
"type": "object",
"properties": {
"fqn": { "type": "string" },
"depth": { "type": "integer" }
},
"required": ["fqn"]
})),
tool_def("graph_stats", "Node and edge counts for the in-memory graph", json!({
"type": "object",
"properties": {}
})),
]
}
fn tool_def(name: &str, description: &str, schema: Value) -> Value {
json!({
"name": name,
"description": description,
"inputSchema": schema
})
}
async fn handle_tool_call(graph: &Arc<RwLock<InMemoryGraph>>, params: Option<Value>) -> Value {
let Some(params) = params else {
return tool_error("missing params");
};
let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
let args = params
.get("arguments")
.cloned()
.unwrap_or_else(|| json!({}));
let g = graph.read().await;
let result = match name {
"find_symbol" => {
let query = args.get("query").and_then(|v| v.as_str()).unwrap_or("");
serde_json::to_value(g.find_symbol(query)).unwrap_or_else(|e| json!({ "error": e.to_string() }))
}
"callers" => {
let fqn = args.get("fqn").and_then(|v| v.as_str()).unwrap_or("");
serde_json::to_value(g.callers(fqn)).unwrap_or_else(|e| json!({ "error": e.to_string() }))
}
"callees" => {
let fqn = args.get("fqn").and_then(|v| v.as_str()).unwrap_or("");
serde_json::to_value(g.callees(fqn)).unwrap_or_else(|e| json!({ "error": e.to_string() }))
}
"file_dependencies" => {
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
json!(g.file_dependencies(path))
}
"symbols_in_file" => {
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
serde_json::to_value(g.symbols_in_file(path)).unwrap_or_else(|e| json!({ "error": e.to_string() }))
}
"impact_analysis" => {
let fqn = args.get("fqn").and_then(|v| v.as_str()).unwrap_or("");
let depth = args.get("depth").and_then(|v| v.as_u64()).unwrap_or(2) as u32;
serde_json::to_value(g.impact(fqn, QueryLimits { max_depth: depth, ..Default::default() }))
.unwrap_or_else(|e| json!({ "error": e.to_string() }))
}
"graph_stats" => json!({
"nodes": g.node_count(),
"edges": g.edge_count()
}),
_ => json!({ "error": format!("unknown tool: {name}") }),
};
json!({
"content": [{ "type": "text", "text": serde_json::to_string_pretty(&result).unwrap_or_default() }]
})
}
fn tool_error(msg: &str) -> Value {
json!({
"content": [{ "type": "text", "text": msg }],
"isError": true
})
}
fn write_error(stdout: &mut impl Write, id: Value, code: i32, message: String) -> io::Result<()> {
let resp = JsonRpcResponse {
jsonrpc: "2.0",
id,
result: None,
error: Some(JsonRpcError { code, message }),
};
serde_json::to_writer(&mut *stdout, &resp)?;
stdout.write_all(b"\n")?;
stdout.flush()
}