escriba-mcp 0.1.10

MCP server for escriba — autogenerated tools from the OpenAPI spec, stdio transport. LLMs drive the editor.
//! `escriba-mcp` — Model Context Protocol server.
//!
//! Autogenerated from escriba-spec: every Command becomes an MCP tool. The
//! binary speaks stdio JSON-RPC; an LLM drives the editor by calling tools
//! like `run_command`, `open_file`, `get_buffer_summary`, `insert_text`.
//!
//! Phase 1.B: static tool catalog derived from CommandRegistry + a stdio
//! dispatcher. Phase 2: live editor-state attachment so an LLM can read
//! buffer contents and make structural edits.

use escriba_command::CommandRegistry;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpTool {
    pub name: String,
    pub description: String,
    pub input_schema: Value,
}

/// The static tool catalog — one tool per registered escriba command plus
/// a few meta-tools.
#[must_use]
pub fn tool_catalog() -> Vec<McpTool> {
    let mut out = Vec::new();

    // Meta tools.
    out.push(McpTool {
        name: "get_spec".to_string(),
        description: "Return the escriba OpenAPI 3.1 spec as JSON.".to_string(),
        input_schema: json!({ "type": "object", "properties": {} }),
    });
    out.push(McpTool {
        name: "list_commands".to_string(),
        description: "Return every registered escriba command with its description.".to_string(),
        input_schema: json!({ "type": "object", "properties": {} }),
    });
    out.push(McpTool {
        name: "list_keymap".to_string(),
        description: "Return every keybinding active in the current session.".to_string(),
        input_schema: json!({ "type": "object", "properties": {} }),
    });

    // One tool per command.
    let reg = CommandRegistry::default_set();
    for c in reg.specs() {
        out.push(McpTool {
            name: format!("run_{}", c.name.replace('-', "_")),
            description: c.description.clone(),
            input_schema: json!({
                "type": "object",
                "properties": {
                    "args": { "type": "array", "items": { "type": "string" } }
                },
                "additionalProperties": false
            }),
        });
    }

    out
}

/// JSON-RPC request / response types — minimal MCP subset.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpRequest {
    pub jsonrpc: String,
    pub id: Option<Value>,
    pub method: String,
    #[serde(default)]
    pub params: Value,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpResponse {
    pub jsonrpc: String,
    pub id: Option<Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub result: Option<Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<McpError>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpError {
    pub code: i32,
    pub message: String,
}

/// Handle one MCP request — dispatch by method.
#[must_use]
pub fn handle(req: &McpRequest) -> McpResponse {
    let result = match req.method.as_str() {
        "initialize" => Ok(json!({
            "protocolVersion": "2024-11-05",
            "capabilities": { "tools": {} },
            "serverInfo": { "name": "escriba-mcp", "version": env!("CARGO_PKG_VERSION") }
        })),
        "tools/list" => Ok(json!({ "tools": tool_catalog() })),
        "tools/call" => dispatch_tool(&req.params),
        _ => Err(McpError {
            code: -32601,
            message: format!("method not found: {}", req.method),
        }),
    };
    match result {
        Ok(result) => McpResponse {
            jsonrpc: "2.0".into(),
            id: req.id.clone(),
            result: Some(result),
            error: None,
        },
        Err(err) => McpResponse {
            jsonrpc: "2.0".into(),
            id: req.id.clone(),
            result: None,
            error: Some(err),
        },
    }
}

fn dispatch_tool(params: &Value) -> Result<Value, McpError> {
    let name = params
        .get("name")
        .and_then(Value::as_str)
        .ok_or_else(|| McpError {
            code: -32602,
            message: "missing tool name".into(),
        })?;
    match name {
        "get_spec" => Ok(json!({
            "content": [{
                "type": "text",
                "text": serde_json::to_string_pretty(&escriba_spec::build_spec().0).unwrap_or_default()
            }]
        })),
        "list_commands" => Ok(json!({
            "content": [{
                "type": "text",
                "text": serde_json::to_string_pretty(&CommandRegistry::default_set().specs()).unwrap_or_default()
            }]
        })),
        "list_keymap" => Ok(json!({
            "content": [{
                "type": "text",
                "text": "see the keymap subcommand on the CLI for a session-specific listing"
            }]
        })),
        other if other.starts_with("run_") => Ok(json!({
            "content": [{
                "type": "text",
                "text": format!("phase 2 wires live execution for tool '{other}'")
            }]
        })),
        other => Err(McpError {
            code: -32601,
            message: format!("unknown tool: {other}"),
        }),
    }
}

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

    #[test]
    fn catalog_includes_meta_plus_commands() {
        let cat = tool_catalog();
        assert!(cat.iter().any(|t| t.name == "get_spec"));
        assert!(cat.iter().any(|t| t.name == "list_commands"));
        assert!(cat.iter().any(|t| t.name == "run_save"));
        assert!(cat.iter().any(|t| t.name == "run_quit"));
    }

    #[test]
    fn initialize_returns_server_info() {
        let req = McpRequest {
            jsonrpc: "2.0".into(),
            id: Some(json!(1)),
            method: "initialize".into(),
            params: json!({}),
        };
        let resp = handle(&req);
        let info = resp.result.unwrap();
        assert_eq!(info["serverInfo"]["name"].as_str().unwrap(), "escriba-mcp");
    }

    #[test]
    fn tools_list_returns_catalog() {
        let req = McpRequest {
            jsonrpc: "2.0".into(),
            id: Some(json!(1)),
            method: "tools/list".into(),
            params: json!({}),
        };
        let resp = handle(&req);
        let tools = resp.result.unwrap();
        assert!(tools["tools"].as_array().unwrap().len() >= 3);
    }

    #[test]
    fn unknown_method_errors() {
        let req = McpRequest {
            jsonrpc: "2.0".into(),
            id: Some(json!(1)),
            method: "not-a-method".into(),
            params: json!({}),
        };
        let resp = handle(&req);
        assert!(resp.error.is_some());
        assert_eq!(resp.error.unwrap().code, -32601);
    }
}