crtx-mcp 0.1.1

MCP stdio JSON-RPC 2.0 server for Cortex — tool dispatch, ToolHandler trait, gate wiring (ADR 0045).
Documentation
//! Stdio JSON-RPC 2.0 loop for `cortex serve`.
//!
//! ## Protocol summary (ADR 0045 §1, CR-6)
//!
//! - One request per line on stdin; one response per line on stdout.
//! - All diagnostic output goes to stderr via `tracing`. Stdout carries only
//!   well-formed JSON-RPC responses.
//! - Notifications (requests where `id` is absent or `null`) produce no
//!   response.
//! - EOF on stdin causes a clean shutdown (returns `Ok(())`).
//!
//! ## Error codes
//!
//! | Code    | Meaning |
//! |---------|---------|
//! | -32700  | Parse error — malformed JSON or missing `jsonrpc`/`method` |
//! | -32601  | Method not found — no tool registered for the method name |
//! | -32000  | Application error — tool returned [`ToolError`] |
//!
//! [`ToolError`]: crate::tool_handler::ToolError

use std::io::{BufRead, Write};

use serde_json::{json, Value};

use crate::tool_registry::ToolRegistry;

/// Run the stdio JSON-RPC 2.0 loop until EOF.
///
/// Reads lines from stdin, dispatches each non-empty line to
/// [`handle_line`], and writes any resulting response to stdout followed
/// by a newline.
///
/// Returns `Ok(())` on clean EOF. Returns `Err` only on an I/O error
/// reading from stdin or writing to stdout.
pub fn run_stdio_server(registry: ToolRegistry) -> Result<(), std::io::Error> {
    let stdin = std::io::stdin();
    let stdout = std::io::stdout();
    tracing::info!("mcp stdio server started");
    for line in stdin.lock().lines() {
        let line = line?;
        if line.trim().is_empty() {
            continue;
        }
        if let Some(resp) = handle_line(&line, &registry) {
            let mut out = stdout.lock();
            serde_json::to_writer(&mut out, &resp).map_err(std::io::Error::other)?;
            writeln!(out)?;
            out.flush()?;
        }
    }
    tracing::info!("mcp stdio server shutdown — EOF");
    Ok(())
}

/// Parse one line and produce a JSON-RPC response, or `None` for notifications.
///
/// This is the core dispatch function. It is `pub` to allow unit testing
/// without starting the full I/O loop.
pub fn handle_line(line: &str, registry: &ToolRegistry) -> Option<Value> {
    // --- parse ---
    let request: Value = match serde_json::from_str(line) {
        Ok(v) => v,
        Err(e) => {
            tracing::warn!(error = %e, "json parse failure");
            // Parse errors have no id to echo back.
            return Some(error_response(Value::Null, -32700, "parse error"));
        }
    };

    // Validate `jsonrpc` field.
    if request.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
        tracing::warn!("missing or invalid jsonrpc field");
        return Some(error_response(
            Value::Null,
            -32700,
            "parse error: jsonrpc must be \"2.0\"",
        ));
    }

    // Extract `method`.
    let method = match request.get("method").and_then(Value::as_str) {
        Some(m) => m,
        None => {
            tracing::warn!("missing method field");
            return Some(error_response(
                Value::Null,
                -32700,
                "parse error: method field missing",
            ));
        }
    };

    // Determine `id`. A missing `id` or `"id": null` means notification.
    let id_field = request.get("id");
    let id: Value = match id_field {
        None => {
            // Notification — no response.
            tracing::debug!(method, "notification (no id) — no response emitted");
            return None;
        }
        Some(Value::Null) => {
            // Explicit null id — also a notification.
            tracing::debug!(method, "notification (id=null) — no response emitted");
            return None;
        }
        Some(v) => v.clone(),
    };

    // Extract `params`, defaulting to null if absent.
    let params = request.get("params").cloned().unwrap_or(Value::Null);

    // --- dispatch ---
    match registry.dispatch(method, params) {
        None => {
            tracing::warn!(method, "method not found");
            Some(error_response(id, -32601, "method not found"))
        }
        Some(Ok(result)) => {
            tracing::debug!(method, "tool call succeeded");
            Some(success_response(id, result))
        }
        Some(Err(e)) => {
            tracing::warn!(method, error = %e, "tool call failed");
            Some(error_response(id, -32000, &e.to_string()))
        }
    }
}

// --- response helpers ---

fn success_response(id: Value, result: Value) -> Value {
    json!({
        "jsonrpc": "2.0",
        "result": result,
        "id": id
    })
}

fn error_response(id: Value, code: i32, message: &str) -> Value {
    json!({
        "jsonrpc": "2.0",
        "error": {
            "code": code,
            "message": message
        },
        "id": id
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tool_handler::{GateId, ToolError, ToolHandler};
    use crate::tool_registry::ToolRegistry;

    struct EchoTool;

    impl ToolHandler for EchoTool {
        fn name(&self) -> &'static str {
            "echo"
        }
        fn gate_set(&self) -> &'static [GateId] {
            &[GateId::HealthRead]
        }
        fn call(&self, params: serde_json::Value) -> Result<serde_json::Value, ToolError> {
            Ok(params)
        }
    }

    fn registry_with_echo() -> ToolRegistry {
        let mut r = ToolRegistry::new();
        r.register(Box::new(EchoTool));
        r
    }

    #[test]
    fn parse_error_returns_minus32700() {
        let r = ToolRegistry::new();
        let resp = handle_line("not json {{{", &r).unwrap();
        assert_eq!(resp["error"]["code"], -32700);
        assert_eq!(resp["id"], Value::Null);
    }

    #[test]
    fn missing_jsonrpc_field_returns_parse_error() {
        let r = ToolRegistry::new();
        let resp = handle_line(r#"{"method":"echo","id":1}"#, &r).unwrap();
        assert_eq!(resp["error"]["code"], -32700);
    }

    #[test]
    fn method_not_found_returns_minus32601() {
        let r = ToolRegistry::new();
        let resp = handle_line(r#"{"jsonrpc":"2.0","method":"nope","id":1}"#, &r).unwrap();
        assert_eq!(resp["error"]["code"], -32601);
        assert_eq!(resp["id"], 1);
    }

    #[test]
    fn notification_with_null_id_returns_none() {
        let r = registry_with_echo();
        let result = handle_line(
            r#"{"jsonrpc":"2.0","method":"echo","params":{},"id":null}"#,
            &r,
        );
        assert!(result.is_none());
    }

    #[test]
    fn notification_without_id_returns_none() {
        let r = registry_with_echo();
        let result = handle_line(r#"{"jsonrpc":"2.0","method":"echo","params":{}}"#, &r);
        assert!(result.is_none());
    }

    #[test]
    fn successful_call_returns_result() {
        let r = registry_with_echo();
        let resp = handle_line(
            r#"{"jsonrpc":"2.0","method":"echo","params":{"x":42},"id":"req-1"}"#,
            &r,
        )
        .unwrap();
        assert_eq!(resp["result"]["x"], 42);
        assert_eq!(resp["id"], "req-1");
        assert!(resp.get("error").is_none());
    }
}