arcature-cli 2026.1.1

Developer lifecycle CLI for Arcature applications.
Documentation
//! JSON-RPC 2.0 wire types for the MCP stdio transport.
//!
//! MCP-over-stdio is JSON-RPC 2.0 over stdin/stdout. This module owns the
//! request/response envelopes and the bounded parsing of method names and
//! tool arguments — the choke points where hostile input is size-bounded
//! before any tool runs (§29 abuse limits). No MCP SDK crate is admitted;
//! the envelope is hand-rolled on `serde_json` (already a workspace
//! dependency).
//!
//! # Bounds (§29)
//!
//! * [`MAX_METHOD_LEN`] — a JSON-RPC method name is rejected above this
//!   length before dispatch. An attacker cannot drive an unbounded string
//!   through the method field.
//! * [`MAX_TOOL_NAME_LEN`] — a `tools/call` tool name is rejected above
//!   this length before lookup.
//! * Tool argument bounds are enforced per-tool (see [`tools`]).
//!
//! Hostile input (malformed JSON, unknown methods, huge fields) is mapped
//! to a typed [`crate::commands::mcp::error::McpError`], never a panic
//! (AGENTS.md §17: hostile protocol input must not panic).

use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::commands::mcp::error::{McpError, RpcError};

/// The JSON-RPC version string the server speaks.
pub(crate) const JSONRPC_VERSION: &str = "2.0";

/// Maximum accepted JSON-RPC method-name length (bytes). Bounds the method
/// field against an attacker-controlled unbounded string (§29).
pub(crate) const MAX_METHOD_LEN: usize = 128;

/// Maximum accepted `tools/call` tool-name length (bytes).
pub(crate) const MAX_TOOL_NAME_LEN: usize = 128;

/// A JSON-RPC 2.0 request id. May be a number or a string per the spec;
/// `null` is treated as a notification (no response) by the server.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(untagged)]
pub(crate) enum RequestId {
    Num(i64),
    Str(String),
    Null,
}

/// A JSON-RPC 2.0 request envelope.
#[derive(Debug, Deserialize)]
pub(crate) struct Request {
    pub(crate) jsonrpc: String,
    /// `id` is optional: a request without an id is a notification (no
    /// response). Malformed input that omits it parses to `None`.
    #[serde(default)]
    pub(crate) id: Option<RequestId>,
    pub(crate) method: String,
    /// The params object; arbitrary JSON. Tools parse their own args from
    /// here with per-field bounds.
    #[serde(default)]
    pub(crate) params: Value,
}

/// A JSON-RPC 2.0 response envelope. Exactly one of `result` / `error` is
/// set; the server constructs via [`Response::ok`] / [`Response::error`].
#[derive(Debug, Serialize)]
pub(crate) struct Response {
    pub(crate) jsonrpc: &'static str,
    pub(crate) id: ResponseId,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) result: Option<Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) error: Option<RpcError>,
}

/// The response-side id. A response to a notification (no id) is still
/// emitted with `null` so the wire shape is uniform; the server simply does
/// not emit responses to notifications at all (see [`dispatch`]).
///
/// `#[serde(untagged)]` so a numeric request id echoes on the wire as a bare
/// JSON number (`"id":2`) and a string id as a bare JSON string
/// (`"id":"abc"`), matching JSON-RPC 2.0's requirement that the response id
/// equals the request id verbatim (§15: wire compatibility, not invention —
/// a stock MCP/JSON-RPC client must recognize the id without knowing
/// Arcature exists). The default externally-tagged representation would
/// emit `"id":{"Num":2}`, which no standard client would accept.
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub(crate) enum ResponseId {
    Num(i64),
    Str(String),
    Null,
}

impl Response {
    pub(crate) fn ok(id: ResponseId, result: Value) -> Self {
        Self {
            jsonrpc: JSONRPC_VERSION,
            id,
            result: Some(result),
            error: None,
        }
    }

    pub(crate) fn error(id: ResponseId, error: McpError) -> Self {
        Self {
            jsonrpc: JSONRPC_VERSION,
            id,
            result: None,
            error: Some(RpcError::from_mcp(&error)),
        }
    }
}

/// Validate and normalize a parsed [`Request`] into a bounded
/// [`ParsedRequest`]. This is where method/tool-name lengths are enforced
/// before dispatch. Returns a typed error (never panics) on a hostile or
/// oversized field.
pub(crate) fn validate(request: Request) -> Result<ParsedRequest, McpError> {
    if request.jsonrpc != JSONRPC_VERSION {
        // A wrong/missing `jsonrpc` field is a protocol error. We do not
        // relay the upstream value; the fixed message is enough.
        return Err(McpError::UnknownMethod("(bad jsonrpc version)".to_owned()));
    }
    if request.method.len() > MAX_METHOD_LEN {
        return Err(McpError::ArgumentTooLarge {
            field: "method",
            limit: MAX_METHOD_LEN,
            observed: request.method.len(),
        });
    }
    Ok(ParsedRequest {
        id: request.id,
        method: request.method,
        params: request.params,
    })
}

/// A request that has passed bounds validation and is ready for dispatch.
#[derive(Debug)]
pub(crate) struct ParsedRequest {
    pub(crate) id: Option<RequestId>,
    pub(crate) method: String,
    pub(crate) params: Value,
}

impl RequestId {
    /// Convert to the response-side id for echoing back in a response.
    pub(crate) fn to_response(&self) -> ResponseId {
        match self {
            Self::Num(n) => ResponseId::Num(*n),
            Self::Str(s) => ResponseId::Str(s.clone()),
            Self::Null => ResponseId::Null,
        }
    }
}

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

    #[test]
    fn parses_a_full_request() {
        let json = r#"{"jsonrpc":"2.0","id":7,"method":"tools/list","params":{}}"#;
        let req: Request = serde_json::from_str(json).expect("parse");
        assert_eq!(req.id, Some(RequestId::Num(7)));
        assert_eq!(req.method, "tools/list");
    }

    #[test]
    fn parses_a_notification_without_id() {
        let json = r#"{"jsonrpc":"2.0","method":"initialized","params":{}}"#;
        let req: Request = serde_json::from_str(json).expect("parse");
        assert!(req.id.is_none());
    }

    #[test]
    fn parses_a_string_id() {
        let json = r#"{"jsonrpc":"2.0","id":"abc","method":"ping","params":{}}"#;
        let req: Request = serde_json::from_str(json).expect("parse");
        assert_eq!(req.id, Some(RequestId::Str("abc".to_owned())));
    }

    #[test]
    fn validate_rejects_wrong_jsonrpc_version() {
        let req = Request {
            jsonrpc: "1.0".to_owned(),
            id: Some(RequestId::Num(1)),
            method: "x".to_owned(),
            params: Value::Null,
        };
        assert!(validate(req).is_err());
    }

    #[test]
    fn validate_rejects_oversized_method() {
        let huge = "x".repeat(MAX_METHOD_LEN + 1);
        let req = Request {
            jsonrpc: JSONRPC_VERSION.to_owned(),
            id: Some(RequestId::Num(1)),
            method: huge,
            params: Value::Null,
        };
        let err = validate(req).expect_err("oversized method");
        assert!(
            matches!(
                err,
                McpError::ArgumentTooLarge {
                    field: "method",
                    ..
                }
            ),
            "{err}"
        );
    }

    #[test]
    fn validate_accepts_well_formed_request() {
        let req = Request {
            jsonrpc: JSONRPC_VERSION.to_owned(),
            id: Some(RequestId::Num(1)),
            method: "tools/list".to_owned(),
            params: Value::Object(Default::default()),
        };
        let parsed = validate(req).expect("ok");
        assert_eq!(parsed.method, "tools/list");
    }

    #[test]
    fn response_ok_serializes_result_and_omits_error() {
        let resp = Response::ok(ResponseId::Num(1), Value::String("ok".into()));
        let json = serde_json::to_string(&resp).expect("serialize");
        assert!(json.contains("\"result\":\"ok\""));
        assert!(!json.contains("\"error\""));
    }

    #[test]
    fn response_error_serializes_error_and_omits_result() {
        let resp = Response::error(ResponseId::Num(1), McpError::UnknownTool("x".into()));
        let json = serde_json::to_string(&resp).expect("serialize");
        assert!(json.contains("\"error\""));
        assert!(json.contains("\"code\":-32602"));
        assert!(!json.contains("\"result\""));
    }

    #[test]
    fn malformed_json_is_a_typed_error_not_a_panic() {
        let result: Result<Request, _> = serde_json::from_str("{ not json");
        assert!(result.is_err());
        // The dispatch layer maps the serde error to McpError; here we just
        // confirm parsing does not panic.
    }
}