sloc-mcp 1.5.6

MCP stdio server exposing oxide-sloc analysis tools to AI agents
use crate::config::McpConfig;
use crate::http_client::HttpClient;
use crate::protocol::{McpRequest, McpResponse, INTERNAL_ERROR, INVALID_PARAMS, METHOD_NOT_FOUND};
use crate::tools::{analyze, compare, ingest, metrics};
use serde_json::{json, Value};
use std::sync::Arc;

pub struct McpServer {
    config: Arc<McpConfig>,
    http: Arc<HttpClient>,
}

impl McpServer {
    pub fn new(config: McpConfig) -> Self {
        let http = HttpClient::new(config.api_key.clone());
        Self {
            config: Arc::new(config),
            http: Arc::new(http),
        }
    }

    pub fn tool_list() -> Value {
        json!([
            {
                "name": "analyze_path",
                "description": "Run oxide-sloc on a local directory or file and return SLOC metrics \
                    (code lines, comment lines, functions, test count, per-language breakdown).",
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "path": { "type": "string", "description": "Path to analyze." },
                        "config_file": { "type": "string", "description": "Optional .oxide-sloc.toml path." }
                    },
                    "required": ["path"]
                }
            },
            {
                "name": "get_metrics_latest",
                "description": "Fetch the most recent scan metrics from a running oxide-sloc server.",
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "server_url": { "type": "string", "description": "Server base URL. Defaults to SLOC_SERVER_URL." }
                    }
                }
            },
            {
                "name": "get_metrics_history",
                "description": "Fetch time-series scan history from a running oxide-sloc server.",
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "server_url": { "type": "string" },
                        "limit": { "type": "integer", "description": "Max entries to return (default 100)." }
                    }
                }
            },
            {
                "name": "get_run_metrics",
                "description": "Fetch metrics for a specific run_id from a running oxide-sloc server.",
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "run_id": { "type": "string", "description": "UUID of the run." },
                        "server_url": { "type": "string" }
                    },
                    "required": ["run_id"]
                }
            },
            {
                "name": "compare_runs",
                "description": "Compare two oxide-sloc JSON result files and return a SLOC delta summary.",
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "baseline_path": { "type": "string", "description": "Path to baseline JSON." },
                        "current_path": { "type": "string", "description": "Path to current JSON." }
                    },
                    "required": ["baseline_path", "current_path"]
                }
            },
            {
                "name": "health_check",
                "description": "Verify a running oxide-sloc server is reachable and return its version.",
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "server_url": { "type": "string" }
                    }
                }
            },
            {
                "name": "ingest_result",
                "description": "POST an AnalysisRun JSON into a running server's registry.",
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "server_url": { "type": "string" },
                        "json_path": { "type": "string", "description": "Path to JSON file on disk." },
                        "json_inline": { "type": "string", "description": "Raw JSON string." }
                    }
                }
            }
        ])
    }

    pub async fn dispatch(&self, req: McpRequest) -> McpResponse {
        match req.method.as_str() {
            "initialize" => McpResponse::ok(
                req.id,
                json!({
                    "protocolVersion": "2024-11-05",
                    "capabilities": { "tools": {} },
                    "serverInfo": {
                        "name": "sloc-mcp",
                        "version": env!("CARGO_PKG_VERSION")
                    }
                }),
            ),

            "tools/list" => McpResponse::ok(req.id, json!({ "tools": Self::tool_list() })),

            "tools/call" => {
                let params = req.params.unwrap_or(Value::Null);
                let name = params["name"].as_str().unwrap_or("").to_owned();
                let args = params["arguments"].clone();
                self.call_tool(req.id, &name, args).await
            }

            _ => McpResponse::err(
                req.id,
                METHOD_NOT_FOUND,
                format!("unknown method: {}", req.method),
            ),
        }
    }

    async fn call_tool(&self, id: Value, name: &str, args: Value) -> McpResponse {
        let result = match name {
            "analyze_path" => {
                let path = match string_arg(&args, "path") {
                    Ok(v) => v,
                    Err(e) => return McpResponse::err(id, INVALID_PARAMS, e.to_string()),
                };
                analyze::analyze_path(path, opt_string(&args, "config_file"), &self.config).await
            }

            "get_metrics_latest" => {
                metrics::get_metrics_latest(
                    opt_string(&args, "server_url"),
                    &self.config,
                    &self.http,
                )
                .await
            }

            "get_metrics_history" => {
                let limit = args["limit"].as_u64();
                metrics::get_metrics_history(
                    opt_string(&args, "server_url"),
                    limit,
                    &self.config,
                    &self.http,
                )
                .await
            }

            "get_run_metrics" => {
                let run_id = match string_arg(&args, "run_id") {
                    Ok(v) => v,
                    Err(e) => return McpResponse::err(id, INVALID_PARAMS, e.to_string()),
                };
                metrics::get_run_metrics(
                    run_id,
                    opt_string(&args, "server_url"),
                    &self.config,
                    &self.http,
                )
                .await
            }

            "compare_runs" => {
                let baseline = match string_arg(&args, "baseline_path") {
                    Ok(v) => v,
                    Err(e) => return McpResponse::err(id, INVALID_PARAMS, e.to_string()),
                };
                let current = match string_arg(&args, "current_path") {
                    Ok(v) => v,
                    Err(e) => return McpResponse::err(id, INVALID_PARAMS, e.to_string()),
                };
                compare::compare_runs(baseline, current, &self.config).await
            }

            "health_check" => {
                metrics::health_check(opt_string(&args, "server_url"), &self.config, &self.http)
                    .await
            }

            "ingest_result" => {
                ingest::ingest_result(
                    opt_string(&args, "server_url"),
                    opt_string(&args, "json_path"),
                    opt_string(&args, "json_inline"),
                    &self.config,
                    &self.http,
                )
                .await
            }

            _ => return McpResponse::err(id, METHOD_NOT_FOUND, format!("unknown tool: {name}")),
        };

        match result {
            Ok(value) => McpResponse::ok(
                id,
                json!({
                    "content": [{ "type": "text", "text": value.to_string() }]
                }),
            ),
            Err(e) => {
                tracing::error!(tool = name, error = %e, "tool call failed");
                McpResponse::err(id, INTERNAL_ERROR, e.to_string())
            }
        }
    }
}

fn string_arg(args: &Value, key: &str) -> anyhow::Result<String> {
    args[key]
        .as_str()
        .map(|s| s.to_owned())
        .ok_or_else(|| anyhow::anyhow!("missing required argument: {key}"))
}

fn opt_string(args: &Value, key: &str) -> Option<String> {
    args[key].as_str().map(|s| s.to_owned())
}