arcature-cli 2026.2.0

Developer lifecycle CLI for Arcature applications.
Documentation
//! Shared tool-argument parsing — bounded string extraction (§29).
//!
//! Every tool parses its own arguments with a per-field size bound so an
//! attacker-controlled MCP client cannot drive unbounded work through a
//! tool call. The shared [`MAX_STRING_ARG`] bound and the two extractors
//! below are used uniformly by tools that take a text argument, so the
//! bound is consistent across the tool surface.
//!
//! One file = one responsibility (AGENTS.md §1): this file owns argument
//! parsing/bounding only — not any tool implementation, the registry, or
//! the wire types.

/// Maximum accepted string-argument length (bytes) for any tool. Bounds
/// every text field an MCP client can pass (route names, doc queries). A
/// tool that needs a different bound declares it locally; this is the
/// shared default.
pub(crate) const MAX_STRING_ARG: usize = 1024;

/// Parse a string field from a tool's arguments object, enforcing the
/// shared [`MAX_STRING_ARG`] bound. Returns a typed error on a missing,
/// non-string, or oversized field. Used by every tool that takes a text
/// argument so the bound is uniform (§29).
///
/// `null` arguments (a client sending `arguments: null`, or a tool called
/// directly with `Value::Null` in tests) are treated as "no arguments", so
/// a required field is reported as missing rather than rejecting the whole
/// call as "arguments must be an object" — robust to a real MCP client
/// sending `arguments: null` (AGENTS.md §17/§29). Only a genuinely wrong
/// type (string, number, array, bool) is rejected as a malformed-arguments
/// error.
pub(crate) fn parse_bounded_string(
    arguments: &serde_json::Value,
    field: &'static str,
) -> Result<String, crate::commands::mcp::error::McpError> {
    use crate::commands::mcp::error::McpError;
    let map = match arguments {
        serde_json::Value::Object(m) => m,
        // `null` means "no arguments" — a required field is therefore
        // missing, reported as "missing argument" (not "arguments must be
        // an object").
        serde_json::Value::Null => {
            return Err(McpError::InvalidArgument {
                message: "missing argument",
            });
        }
        _ => {
            return Err(McpError::InvalidArgument {
                message: "arguments must be an object",
            });
        }
    };
    let value = map.get(field).ok_or(McpError::InvalidArgument {
        message: "missing argument",
    })?;
    let s = match value {
        serde_json::Value::String(s) => s.clone(),
        _ => {
            return Err(McpError::InvalidArgument {
                message: "argument must be a string",
            });
        }
    };
    if s.len() > MAX_STRING_ARG {
        return Err(McpError::ArgumentTooLarge {
            field,
            limit: MAX_STRING_ARG,
            observed: s.len(),
        });
    }
    Ok(s)
}

/// Parse an optional string field; `None` if absent. When present, enforces
/// the [`MAX_STRING_ARG`] bound.
///
/// Like [`parse_bounded_string`], `null` arguments are treated as "no
/// arguments" (returning `None` here, since no optional field is present) —
/// robust to a client sending `arguments: null` (§17/§29).
pub(crate) fn parse_optional_bounded_string(
    arguments: &serde_json::Value,
    field: &'static str,
) -> Result<Option<String>, crate::commands::mcp::error::McpError> {
    use crate::commands::mcp::error::McpError;
    let map = match arguments {
        serde_json::Value::Object(m) => m,
        // `null` (or absent, via dispatch's default) means "no arguments" —
        // no optional field is present, so return None.
        serde_json::Value::Null => return Ok(None),
        _ => {
            return Err(McpError::InvalidArgument {
                message: "arguments must be an object",
            });
        }
    };
    match map.get(field) {
        None => Ok(None),
        Some(serde_json::Value::String(s)) => {
            if s.len() > MAX_STRING_ARG {
                return Err(McpError::ArgumentTooLarge {
                    field,
                    limit: MAX_STRING_ARG,
                    observed: s.len(),
                });
            }
            Ok(Some(s.clone()))
        }
        Some(_) => Err(McpError::InvalidArgument {
            message: "argument must be a string",
        }),
    }
}