impactsense-parser 0.1.0

Multi-language static analysis: parse codebases into an in-memory dependency graph for impact analysis
Documentation
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 {
    /// Workspace root to parse and watch.
    #[arg(long, env = "IMPACTSENSE_ROOT")]
    root: PathBuf,

    /// Debounce interval for file watcher events (milliseconds).
    #[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()
}