crtx-mcp 0.1.2

MCP stdio JSON-RPC 2.0 server for Cortex — tool dispatch, ToolHandler trait, gate wiring (ADR 0045).
Documentation
//! JSON-RPC 2.0 protocol layer integration tests for `cortex-mcp`.
//!
//! These tests exercise `serve::handle_line` directly — no I/O loop, no
//! stdin/stdout mocking required. Each test builds a [`ToolRegistry`],
//! optionally registers a mock handler, then calls `handle_line` and
//! asserts on the returned [`serde_json::Value`].
//!
//! Error codes tested:
//! - `-32700` parse error (malformed JSON, missing `jsonrpc`/`method`)
//! - `-32601` method not found
//! - `-32000` application error (tool returned [`ToolError`])

use cortex_mcp::serve::handle_line;
use cortex_mcp::tool_handler::{GateId, ToolError, ToolHandler};
use cortex_mcp::tool_registry::ToolRegistry;
use serde_json::Value;

// ---------------------------------------------------------------------------
// Mock tool helpers
// ---------------------------------------------------------------------------

/// A no-op tool that echoes its params back as the result.
struct EchoTool;

impl ToolHandler for EchoTool {
    fn name(&self) -> &'static str {
        "mock_echo"
    }

    fn gate_set(&self) -> &'static [GateId] {
        &[GateId::HealthRead]
    }

    fn call(&self, params: Value) -> Result<Value, ToolError> {
        Ok(params)
    }
}

/// A tool that unconditionally returns `ToolError::InvalidParams`.
struct FailingTool;

impl ToolHandler for FailingTool {
    fn name(&self) -> &'static str {
        "mock_fail"
    }

    fn gate_set(&self) -> &'static [GateId] {
        &[GateId::HealthRead]
    }

    fn call(&self, _params: Value) -> Result<Value, ToolError> {
        Err(ToolError::InvalidParams("deliberately bad".into()))
    }
}

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

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

fn registry_with_suggest() -> ToolRegistry {
    use cortex_mcp::tools::suggest::CortexSuggestTool;
    use rusqlite::Connection;
    use std::sync::{Arc, Mutex};
    let conn = Connection::open_in_memory().expect("in-memory sqlite");
    cortex_store::migrate::apply_pending(&conn).expect("apply_pending");
    let pool = Arc::new(Mutex::new(conn));
    let mut r = ToolRegistry::new();
    r.register(Box::new(CortexSuggestTool::new(pool)));
    r
}

// ---------------------------------------------------------------------------
// 1. valid_jsonrpc_request_routes_to_tool
// ---------------------------------------------------------------------------

#[test]
fn valid_jsonrpc_request_routes_to_tool() {
    let registry = registry_with_echo();
    let line = r#"{"jsonrpc":"2.0","method":"mock_echo","params":{"key":"value"},"id":42}"#;

    let resp = handle_line(line, &registry).expect("expected a response for a valid request");

    assert!(
        resp.get("error").is_none(),
        "response must not contain an error field: {resp}"
    );
    assert_eq!(
        resp["result"]["key"], "value",
        "result must carry the echoed params"
    );
    assert_eq!(resp["id"], 42, "id must be echoed back");
    assert_eq!(resp["jsonrpc"], "2.0", "jsonrpc version must be 2.0");
}

// ---------------------------------------------------------------------------
// 2. malformed_json_returns_parse_error_32700
// ---------------------------------------------------------------------------

#[test]
fn malformed_json_returns_parse_error_32700() {
    let registry = ToolRegistry::new();

    let resp =
        handle_line("not json", &registry).expect("parse error must produce a response, not None");

    assert_eq!(
        resp["error"]["code"], -32700,
        "malformed JSON must return code -32700: {resp}"
    );
    assert_eq!(
        resp["id"],
        Value::Null,
        "id must be null when request cannot be parsed"
    );
}

// ---------------------------------------------------------------------------
// 3. unknown_method_returns_method_not_found_32601
// ---------------------------------------------------------------------------

#[test]
fn unknown_method_returns_method_not_found_32601() {
    let registry = ToolRegistry::new();
    let line = r#"{"jsonrpc":"2.0","method":"nonexistent","params":{},"id":1}"#;

    let resp = handle_line(line, &registry).expect("method-not-found must produce a response");

    assert_eq!(
        resp["error"]["code"], -32601,
        "unknown method must return code -32601: {resp}"
    );
    assert_eq!(
        resp["id"], 1,
        "id must be echoed back even on method-not-found"
    );
}

// ---------------------------------------------------------------------------
// 4. tool_error_returns_application_error_32000
// ---------------------------------------------------------------------------

#[test]
fn tool_error_returns_application_error_32000() {
    let registry = registry_with_failing_tool();
    let line = r#"{"jsonrpc":"2.0","method":"mock_fail","params":{},"id":99}"#;

    let resp = handle_line(line, &registry).expect("tool error must produce a response");

    assert_eq!(
        resp["error"]["code"], -32000,
        "ToolError must map to code -32000: {resp}"
    );
    assert_eq!(
        resp["id"], 99,
        "id must be echoed back on application error"
    );

    let message = resp["error"]["message"]
        .as_str()
        .expect("error.message must be a string");
    assert!(
        message.contains("deliberately bad"),
        "error message must surface the ToolError detail: {message}"
    );

    let data = &resp["error"]["data"];
    assert_eq!(
        data["schema"], "cortex_refusal_resolution.v1",
        "application errors must carry a structured resolution envelope: {resp}"
    );
    assert_eq!(data["kind"], "invalid_params");
    assert!(
        data["next_actions"]
            .as_array()
            .is_some_and(|a| !a.is_empty()),
        "resolution envelope must include operator-facing next actions: {data}"
    );
}

// ---------------------------------------------------------------------------
// 5. null_id_is_notification_no_response
// ---------------------------------------------------------------------------

#[test]
fn null_id_is_notification_no_response() {
    let registry = registry_with_echo();
    let line = r#"{"jsonrpc":"2.0","method":"mock_echo","params":{"x":1},"id":null}"#;

    let result = handle_line(line, &registry);

    assert!(
        result.is_none(),
        "a request with id=null is a notification and must produce no response"
    );
}

// ---------------------------------------------------------------------------
// 6. missing_id_is_notification_no_response
// ---------------------------------------------------------------------------

#[test]
fn missing_id_is_notification_no_response() {
    let registry = registry_with_echo();
    // No "id" field at all.
    let line = r#"{"jsonrpc":"2.0","method":"mock_echo","params":{"x":1}}"#;

    let result = handle_line(line, &registry);

    assert!(
        result.is_none(),
        "a request without an id field is a notification and must produce no response"
    );
}

// ---------------------------------------------------------------------------
// 7. cortex_suggest_empty_store_returns_empty_array
// ---------------------------------------------------------------------------

#[test]
fn cortex_suggest_empty_store_returns_empty_array() {
    let registry = registry_with_suggest();
    let line = r#"{"jsonrpc":"2.0","method":"cortex_suggest","params":{},"id":1}"#;

    let resp = handle_line(line, &registry).expect("cortex_suggest must produce a response");

    assert!(
        resp.get("error").is_none(),
        "cortex_suggest must not return an error on an empty store: {resp}"
    );
    assert_eq!(
        resp["result"]["suggestions"],
        serde_json::json!([]),
        "cortex_suggest on empty store must return an empty suggestions array"
    );
    assert!(
        resp["result"]["query_used"].is_null(),
        "cortex_suggest without query must return null query_used"
    );
}

// ---------------------------------------------------------------------------
// 8. oversized_payload_rejected_with_32000
//
// `cortex_session_close` is not yet registered at this HEAD (T-0045.5 is
// deferred). We test the size-limit path using a mock tool that enforces the
// 5 MiB limit defined in ADR 0045 T-0045.5 / RT-5.
//
// The mock returns `ToolError::SizeLimitExceeded` when `events_json` exceeds
// 5_242_880 bytes, mirroring the contract the real tool will enforce.
// ---------------------------------------------------------------------------

struct SizeLimitedTool;

impl ToolHandler for SizeLimitedTool {
    fn name(&self) -> &'static str {
        "mock_size_limited"
    }

    fn gate_set(&self) -> &'static [GateId] {
        &[GateId::SessionWrite, GateId::CommitWrite]
    }

    fn call(&self, params: Value) -> Result<Value, ToolError> {
        const MAX_BYTES: usize = 5_242_880;
        let events_json = params["events_json"]
            .as_str()
            .ok_or_else(|| ToolError::InvalidParams("events_json is required".into()))?;

        if events_json.len() > MAX_BYTES {
            return Err(ToolError::SizeLimitExceeded(
                "events_json exceeds maximum size".into(),
            ));
        }

        Ok(serde_json::json!({ "ingested": 0 }))
    }
}

#[test]
fn oversized_events_json_rejected() {
    let mut registry = ToolRegistry::new();
    registry.register(Box::new(SizeLimitedTool));

    // Build a payload whose events_json string is exactly 5 MiB + 1 byte.
    let oversized_value = "x".repeat(5_242_881);
    let request = serde_json::json!({
        "jsonrpc": "2.0",
        "method": "mock_size_limited",
        "params": { "events_json": oversized_value },
        "id": 1
    });
    let line = serde_json::to_string(&request).expect("serialise request");

    let resp = handle_line(&line, &registry).expect("size-limit error must produce a response");

    assert_eq!(
        resp["error"]["code"], -32000,
        "oversized payload must return code -32000: {resp}"
    );

    let message = resp["error"]["message"]
        .as_str()
        .expect("error.message must be a string");
    assert!(
        message.contains("exceeds maximum size"),
        "error message must mention size limit: {message}"
    );
}