codebase-graph 1.1.6

Native codebaseGraph CLI and MCP server for local code knowledge graphs.
use super::{expand_path, McpInstallOptions};
use crate::cli::{
    constants::server_command,
    setup::{safe_name, GraphStatePaths},
    util::{read_json_file, resolve_repo_root},
};
use serde_json::json;
use std::path::Path;

#[derive(Debug, Clone)]
pub(in crate::cli) struct NativeMcpDescriptor {
    pub(in crate::cli) name: String,
    pub(in crate::cli) command: String,
    pub(in crate::cli) args: Vec<String>,
    pub(in crate::cli) setup_config_path: String,
    pub(in crate::cli) repo_root: String,
    pub(in crate::cli) timeout: u64,
}

impl NativeMcpDescriptor {
    pub(in crate::cli) fn as_json(&self) -> serde_json::Value {
        json!({
            "name": self.name,
            "transport": "stdio",
            "command": self.command,
            "args": self.args,
            "env": {},
            "cwd": serde_json::Value::Null,
            "setup_config_path": self.setup_config_path,
            "repo_root": self.repo_root,
            "timeout": self.timeout,
            "tool_policy": "graph_query_read_only",
        })
    }

    pub(in crate::cli) fn stdio_entry(
        &self,
        include_type: bool,
        include_timeout: bool,
    ) -> serde_json::Value {
        let mut entry = serde_json::Map::new();
        entry.insert("command".to_string(), json!(self.command));
        entry.insert("args".to_string(), json!(self.args));
        if include_type {
            entry.insert("type".to_string(), json!("stdio"));
        }
        if include_timeout {
            entry.insert("startup_timeout_sec".to_string(), json!(self.timeout));
        }
        serde_json::Value::Object(entry)
    }
}

pub(in crate::cli) fn build_mcp_descriptor(
    options: &McpInstallOptions,
) -> Result<NativeMcpDescriptor, String> {
    let resolved_repo_root = resolve_repo_root(options.repo_root.as_deref())?;
    let config_path = options
        .config_path
        .clone()
        .unwrap_or_else(|| GraphStatePaths::derive(&resolved_repo_root).config_path);
    let setup_config = if config_path.exists() {
        Some(read_json_file(&config_path)?)
    } else {
        None
    };
    let repo_root = setup_config
        .as_ref()
        .and_then(|payload| payload.get("repo_root"))
        .and_then(serde_json::Value::as_str)
        .map(expand_path)
        .unwrap_or_else(|| {
            config_path
                .parent()
                .and_then(Path::parent)
                .map(Path::to_path_buf)
                .unwrap_or_else(|| resolved_repo_root.clone())
        });
    let repo_name = setup_config
        .as_ref()
        .and_then(|payload| payload.get("repo_name"))
        .and_then(serde_json::Value::as_str)
        .map(str::to_string)
        .unwrap_or_else(|| {
            safe_name(
                repo_root
                    .file_name()
                    .and_then(|value| value.to_str())
                    .unwrap_or("repository"),
            )
        });
    let name = options
        .name
        .clone()
        .unwrap_or_else(|| format!("codebase_graph_{}", install_safe_name(&repo_name)));
    let command_from_config = setup_config
        .as_ref()
        .and_then(|payload| payload.pointer("/mcp/command"))
        .and_then(serde_json::Value::as_array)
        .and_then(|values| {
            let command: Option<Vec<String>> = values
                .iter()
                .map(|value| value.as_str().map(str::to_string))
                .collect();
            command.filter(|parts| parts.len() >= 5)
        });
    let (command, args) = if let Some(mut parts) = command_from_config {
        let command = parts.remove(0);
        (command, parts)
    } else {
        (
            server_command(),
            vec![
                "mcp".to_string(),
                "start".to_string(),
                "--config".to_string(),
                config_path.to_string_lossy().to_string(),
            ],
        )
    };
    Ok(NativeMcpDescriptor {
        name,
        command,
        args,
        setup_config_path: config_path.to_string_lossy().to_string(),
        repo_root: repo_root.to_string_lossy().to_string(),
        timeout: 60,
    })
}

pub(in crate::cli) fn install_safe_name(value: &str) -> String {
    let normalized: String = value
        .trim()
        .chars()
        .map(|character| {
            if character.is_ascii_alphanumeric() || character == '-' || character == '_' {
                character.to_ascii_lowercase()
            } else {
                '_'
            }
        })
        .collect();
    normalized.trim_matches(['.', '_', '-']).to_string()
}