codebase-graph 1.2.1

Native codebaseGraph CLI and MCP server for local code knowledge graphs.
use super::{block::serialize_error_block, options::McpServeOptions};
use crate::api::{
    ApiError, CodebaseGraphApi, OperationDescriptor, OperationInvocation, OperationResponse,
    OutputFormat,
};
use serde_json::json;
use serde_json::Map;

pub(in crate::adapters) fn generate_mcp_specs() -> Result<serde_json::Value, String> {
    let tools = CodebaseGraphApi::new()
        .operation_descriptors()
        .into_iter()
        .filter(|operation| operation.surfaces.contains(&"mcp"))
        .filter_map(operation_spec_from_descriptor)
        .collect::<Vec<_>>();
    Ok(json!({"tools": tools}))
}

fn operation_spec_from_descriptor(descriptor: OperationDescriptor) -> Option<serde_json::Value> {
    let name = descriptor.mcp_tool_name?;
    let properties = (descriptor.request_schema)();
    let mut property_map: Map<String, serde_json::Value> =
        properties.as_object().cloned().unwrap_or_default();
    property_map.insert(
        "output_format".to_string(),
        json!({"type":"string","enum":["json","block"],"default":"block"}),
    );
    property_map.insert(
        "include_structured_content".to_string(),
        json!({"type":"boolean","default":false,"description":"Include the MCP structuredContent payload alongside the text result."}),
    );

    Some(json!({
        "name": name,
        "description": descriptor.summary,
        "inputSchema": {
            "type": "object",
            "additionalProperties": false,
            "properties": property_map,
            "required": descriptor.required_fields(),
        },
    }))
}

fn map_error_to_transport(tool_name: &str, error: &ApiError) -> serde_json::Value {
    json!({
        "error": {
            "tool": tool_name,
            "type": "ValueError",
            "code": error.code,
            "message": error.message,
            "details": error.details,
            "retryable": error.retryable,
        }
    })
}

pub(in crate::adapters) fn mcp_call_tool_result(
    tool_name: &str,
    arguments: &serde_json::Value,
    options: &McpServeOptions,
) -> Result<serde_json::Value, String> {
    let output_format = parse_output_format(arguments).map_err(|error| error.message)?;
    let response = mcp_tool_payload(tool_name, arguments, options, output_format);
    let include_structured = arguments
        .get("include_structured_content")
        .and_then(serde_json::Value::as_bool)
        .unwrap_or(false);
    match response {
        Ok(response) => {
            let payload = response.payload;
            let (text, structured) = if output_format == OutputFormat::Typed {
                (
                    serde_json::to_string(&payload).map_err(|error| error.to_string())?,
                    payload.clone(),
                )
            } else {
                let text = payload
                    .get("text")
                    .and_then(serde_json::Value::as_str)
                    .ok_or_else(|| "block response did not contain text".to_string())?
                    .to_string();
                let structured = payload
                    .get("structured")
                    .cloned()
                    .unwrap_or(serde_json::Value::Null);
                (text, structured)
            };
            let mut result = json!({
                "content": [{"type": "text", "text": text}],
                "isError": false,
            });
            if include_structured {
                result["structuredContent"] = structured;
            }
            Ok(result)
        }
        Err(error) if tool_name.is_empty() || error.code == "unknown_tool" => Err(error.message),
        Err(error) => {
            let payload = map_error_to_transport(tool_name, &error);
            let text = if output_format == OutputFormat::Typed {
                serde_json::to_string(&payload).map_err(|error| error.to_string())?
            } else {
                serialize_error_block(&payload)
            };
            let mut result = json!({
                "content": [{"type": "text", "text": text}],
                "isError": true,
            });
            if include_structured {
                result["structuredContent"] = payload;
            }
            Ok(result)
        }
    }
}

pub(in crate::adapters) fn mcp_tool_payload(
    tool_name: &str,
    arguments: &serde_json::Value,
    options: &McpServeOptions,
    output_format: OutputFormat,
) -> Result<OperationResponse, ApiError> {
    let api = options.api.clone().unwrap_or_default();
    let operation = api.resolve_mcp_operation(tool_name).ok_or_else(|| {
        ApiError::new(
            "unknown_tool",
            format!("Unknown codebaseGraph MCP tool: {tool_name}"),
        )
    })?;
    api.execute_invocation(
        operation.id,
        &OperationInvocation {
            repo: options.repo_selector(),
            arguments: arguments.clone(),
            output_format,
        },
    )
}

fn parse_output_format(arguments: &serde_json::Value) -> Result<OutputFormat, ApiError> {
    match arguments
        .get("output_format")
        .and_then(serde_json::Value::as_str)
        .unwrap_or("block")
    {
        "json" => Ok(OutputFormat::Typed),
        "block" => Ok(OutputFormat::Block),
        _ => Err(ApiError::new(
            "invalid_output_format",
            "graph tool output_format must be \"json\" or \"block\"",
        )),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn public_errors_map_to_mcp_failures_without_changing_codes() {
        let error = ApiError::new("graph_busy", "graph is busy")
            .with_details(json!({"operation": "query"}))
            .retryable(true);

        let payload = map_error_to_transport("graph_query", &error);

        assert_eq!(payload["error"]["tool"], "graph_query");
        assert_eq!(payload["error"]["code"], "graph_busy");
        assert_eq!(payload["error"]["message"], "graph is busy");
        assert_eq!(payload["error"]["details"]["operation"], "query");
        assert_eq!(payload["error"]["retryable"], true);
    }

    #[test]
    fn mcp_specs_are_generated_from_registered_operation_metadata() {
        let specs = generate_mcp_specs().expect("MCP specs should generate");
        let tools = specs["tools"].as_array().expect("tools should be an array");
        let names = tools
            .iter()
            .filter_map(|tool| tool["name"].as_str())
            .collect::<Vec<_>>();
        assert_eq!(
            names,
            vec![
                "graph_architecture_queries",
                "graph_context",
                "graph_health",
                "graph_query",
                "graph_query_helpers",
                "graph_schema",
                "graph_search",
            ]
        );
        let search = tools
            .iter()
            .find(|tool| tool["name"] == "graph_search")
            .expect("search tool should be advertised");
        assert_eq!(
            search["inputSchema"]["properties"]["detail"]["enum"],
            json!(["standard", "slim"])
        );
        assert_eq!(
            search["inputSchema"]["properties"]["profile"]["default"],
            "brief"
        );
        assert_eq!(search["inputSchema"]["properties"]["limit"]["default"], 3);
        assert_eq!(search["inputSchema"]["required"], json!(["query"]));
    }
}