arcature-cli 2026.1.1

Developer lifecycle CLI for Arcature applications.
Documentation
//! Request dispatch — routes a validated JSON-RPC request to its handler.
//!
//! This is the single routing choke point between the wire ([`transport`]) and
//! the tools ([`registry`]). A parsed [`ParsedRequest`] is matched on its
//! `method` field and either turned into a [`Response`] or skipped (a
//! notification with no id gets no response — JSON-RPC 2.0 §4).
//!
//! # §19: decide before expensive work
//!
//! The UAG is already loaded (once per session, by [`server`]); dispatch only
//! routes. `tools/call` resolves a tool by name and checks its required
//! capabilities **before** the handler runs — a capability refusal or an
//! unknown tool returns immediately, never invoking the handler. There is no
//! per-call UAG reload.
//!
//! # Hostile input must not panic (AGENTS.md §17)
//!
//! `tools/call` params are parsed defensively: a non-object `params`, a
//! missing/non-string `name`, an oversized `name`, and a non-object
//! `arguments` all map to a typed [`McpError`] and a JSON-RPC error
//! response, never a panic. The tool name is bounded by
//! [`MAX_TOOL_NAME_LEN`] before lookup so an attacker cannot drive an
//! unbounded string through the name field (§29).
//!
//! # One file = one responsibility (AGENTS.md §1)
//!
//! This file owns *routing only*. It does not own the wire types
//! ([`transport`]), the tool registry ([`registry`]), the capability model
//! ([`capability`]), or any tool implementation ([`tools`]). Each method
//! branch is a thin call into the responsible module.

use serde_json::Value;

use crate::commands::mcp::capability::CapabilitySet;
use crate::commands::mcp::error::McpError;
use crate::commands::mcp::registry::{ToolContext, all_tools, find};
use crate::commands::mcp::transport::{MAX_TOOL_NAME_LEN, ParsedRequest, Response, ResponseId};

/// The MCP protocol version this server speaks. Surfaced in the
/// `initialize` result so a client can negotiate. Fixed and versioned with
/// the server; not an Arcature invention — the standard MCP stdio version.
pub(crate) const PROTOCOL_VERSION: &str = "2025-06-18";

/// The server name surfaced in the `initialize` result. Stable; not a
/// release coordinate (distinct from a crate YBF version, the Platform
/// version, and the npm version — ADR-0006 §2).
const SERVER_NAME: &str = "arcature-mcp";

/// The outcome of dispatching one request: either a response to emit, or a
/// notification to skip (no response). The server loop maps `Skip` to "emit
/// nothing" (JSON-RPC 2.0 §4: notifications get no response).
pub(crate) enum DispatchOutcome {
    /// Emit this response on the wire.
    Respond(Response),
    /// A notification (no id): emit nothing.
    Skip,
}

/// Dispatch a validated [`ParsedRequest`] against the granted capabilities
/// and the loaded tool context. Returns the response to emit, or `Skip` for
/// a notification.
///
/// A request without an `id` is a notification (JSON-RPC 2.0 §4) and gets no
/// response regardless of method — this is checked first, before any
/// expensive work, so a notification never allocates a response.
pub(crate) fn dispatch(
    request: ParsedRequest,
    capabilities: &CapabilitySet,
    context: &ToolContext,
) -> DispatchOutcome {
    // §19: a notification (no id) is decided here, before method routing.
    let id = match request.id {
        Some(id) => id,
        None => return DispatchOutcome::Skip,
    };
    let response_id = id.to_response();
    match request.method.as_str() {
        "initialize" => DispatchOutcome::Respond(initialize(response_id, request.params)),
        "ping" => DispatchOutcome::Respond(Response::ok(response_id, Value::Null)),
        "notifications/initialized" | "initialized" => {
            // Standard MCP initialization notification. Has no id in practice
            // (caught above); if a client sent one with an id, acknowledge
            // with an empty result rather than dropping it.
            DispatchOutcome::Respond(Response::ok(response_id, Value::Object(Default::default())))
        }
        "tools/list" => DispatchOutcome::Respond(tools_list(response_id)),
        "tools/call" => DispatchOutcome::Respond(tools_call(
            response_id,
            request.params,
            capabilities,
            context,
        )),
        _ => DispatchOutcome::Respond(Response::error(
            response_id,
            McpError::UnknownMethod(request.method),
        )),
    }
}

/// `initialize` — return the protocol version, server capabilities, and
/// server info. The params are ignored (a client may send a
/// `protocolVersion` it wants; this server speaks only [`PROTOCOL_VERSION`]).
fn initialize(id: ResponseId, _params: Value) -> Response {
    // The capabilities object advertises what the server can do. This server
    // exposes tools; it does not (yet) advertise resources, prompts, or
    // logging. The shape is a fixed JSON object — no upstream `Display` is
    // relayed (redaction discipline).
    let result = serde_json::json!({
        "protocol_version": PROTOCOL_VERSION,
        "capabilities": {
            "tools": {}
        },
        "server_info": {
            "name": SERVER_NAME,
            "version": env!("CARGO_PKG_VERSION")
        }
    });
    Response::ok(id, result)
}

/// `tools/list` — return every registered tool's name and description. The
/// registry is a static slice (no runtime discovery), so this is a fixed
/// projection. Capability requirements are not advertised here per-tool; the
/// gate is enforced at `tools/call` time (a tool that requires an ungranted
/// capability is refused when called, not hidden from the list — a client
/// can discover the tool exists and learn it is gated on call).
fn tools_list(id: ResponseId) -> Response {
    let tools: Vec<Value> = all_tools()
        .iter()
        .map(|tool| {
            serde_json::json!({
                "name": tool.name,
                "description": tool.description,
            })
        })
        .collect();
    Response::ok(id, serde_json::json!({ "tools": tools }))
}

/// `tools/call` — resolve a tool by name, bound the name, check its required
/// capabilities, extract its arguments, invoke its handler, and scrub the
/// result at the boundary (defense-in-depth: a secret a future tool returns
/// is reduced to `[redacted]` before serialization). Every failure path is a
/// typed [`McpError`] mapped to a JSON-RPC error response; no panic.
fn tools_call(
    id: ResponseId,
    params: Value,
    capabilities: &CapabilitySet,
    context: &ToolContext,
) -> Response {
    match tools_call_inner(params, capabilities, context) {
        Ok(result) => Response::ok(id, crate::commands::mcp::redact::scrub_result(result)),
        Err(error) => Response::error(id, error),
    }
}

/// The pure inner: parse params, find the tool, gate capabilities, call the
/// handler. Separated from [`tools_call`] so the `Ok`/`Err` split is clean
/// and the handler's typed error flows into the error response uniformly.
fn tools_call_inner(
    params: Value,
    capabilities: &CapabilitySet,
    context: &ToolContext,
) -> Result<Value, McpError> {
    let map = match params {
        Value::Object(map) => map,
        _ => {
            return Err(McpError::InvalidArgument {
                message: "tools/call params must be an object",
            });
        }
    };
    // Resolve and bound the tool name before any lookup (§29).
    let name = match map.get("name") {
        Some(Value::String(s)) => s.as_str(),
        Some(_) => {
            return Err(McpError::InvalidArgument {
                message: "tools/call `name` must be a string",
            });
        }
        None => {
            return Err(McpError::InvalidArgument {
                message: "tools/call requires a `name` field",
            });
        }
    };
    if name.len() > MAX_TOOL_NAME_LEN {
        return Err(McpError::ArgumentTooLarge {
            field: "name",
            limit: MAX_TOOL_NAME_LEN,
            observed: name.len(),
        });
    }
    // §19: check the capability gate before invoking the handler.
    let tool = find(name).ok_or_else(|| McpError::UnknownTool(name.to_owned()))?;
    capabilities.check(tool.required_capabilities)?;
    // Arguments default to an empty object when absent (a tool that takes no
    // arguments, like `application_info`, ignores them).
    let arguments = map
        .get("arguments")
        .cloned()
        .unwrap_or_else(|| Value::Object(Default::default()));
    (tool.handler)(&arguments, capabilities, context)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cli::McpOptions;
    use crate::commands::mcp::capability::CapabilitySet;
    use crate::commands::mcp::registry::ToolContext;
    use crate::commands::mcp::transport::{JSONRPC_VERSION, RequestId, validate};
    use arcature_build::uag::Uag;
    use std::collections::BTreeMap;

    fn ctx() -> ToolContext {
        ToolContext {
            uag: Uag {
                schema_version: 1,
                application: "App".into(),
                framework_version: "2026.1.0".into(),
                modules: BTreeMap::new(),
                routes: vec![],
                services: vec![],
                pages: vec![],
            },
        }
    }

    fn caps() -> CapabilitySet {
        CapabilitySet::from_options(&McpOptions::default())
    }

    fn parsed(method: &str, id: Option<i64>, params: Value) -> ParsedRequest {
        let request = crate::commands::mcp::transport::Request {
            jsonrpc: JSONRPC_VERSION.to_owned(),
            id: id.map(RequestId::Num),
            method: method.to_owned(),
            params,
        };
        validate(request).expect("valid request")
    }

    fn dispatch_str(outcome: &DispatchOutcome) -> String {
        match outcome {
            DispatchOutcome::Respond(resp) => serde_json::to_string(resp).expect("serialize"),
            DispatchOutcome::Skip => "SKIP".to_owned(),
        }
    }

    #[test]
    fn initialize_returns_protocol_version_and_capabilities() {
        let outcome = dispatch(parsed("initialize", Some(1), Value::Null), &caps(), &ctx());
        let s = dispatch_str(&outcome);
        assert!(s.contains("\"protocol_version\":\"2025-06-18\""), "{s}");
        assert!(s.contains("\"capabilities\""), "{s}");
        assert!(s.contains("\"server_info\""), "{s}");
        assert!(s.contains("arcature-mcp"), "{s}");
    }

    #[test]
    fn ping_returns_a_result() {
        let outcome = dispatch(parsed("ping", Some(2), Value::Null), &caps(), &ctx());
        let s = dispatch_str(&outcome);
        assert!(s.contains("\"result\""), "{s}");
        assert!(s.contains("\"id\":2"), "{s}");
    }

    #[test]
    fn tools_list_returns_every_registered_tool() {
        let outcome = dispatch(parsed("tools/list", Some(3), Value::Null), &caps(), &ctx());
        let s = dispatch_str(&outcome);
        for tool in all_tools() {
            assert!(
                s.contains(tool.name),
                "tools/list missing `{}`: {s}",
                tool.name
            );
        }
    }

    #[test]
    fn tools_call_invokes_a_known_read_only_tool() {
        let params = serde_json::json!({"name": "application_info"});
        let outcome = dispatch(parsed("tools/call", Some(4), params), &caps(), &ctx());
        let s = dispatch_str(&outcome);
        assert!(s.contains("\"result\""), "{s}");
        assert!(s.contains("schema_version"), "{s}");
    }

    #[test]
    fn tools_call_passes_arguments_to_a_tool() {
        let params =
            serde_json::json!({"name": "inspect_route", "arguments": {"route_name": "nope"}});
        let outcome = dispatch(parsed("tools/call", Some(5), params), &caps(), &ctx());
        let s = dispatch_str(&outcome);
        // Unknown route returns an empty result (not an error) per inspect_route.
        assert!(s.contains("\"result\""), "{s}");
        assert!(s.contains("route_name"), "{s}");
    }

    #[test]
    fn tools_call_unknown_tool_is_a_typed_error() {
        let params = serde_json::json!({"name": "nope"});
        let outcome = dispatch(parsed("tools/call", Some(6), params), &caps(), &ctx());
        let s = dispatch_str(&outcome);
        assert!(s.contains("\"error\""), "{s}");
        assert!(s.contains("unknown tool: nope"), "{s}");
    }

    #[test]
    fn tools_call_missing_name_is_a_typed_error() {
        let params = serde_json::json!({});
        let outcome = dispatch(parsed("tools/call", Some(7), params), &caps(), &ctx());
        let s = dispatch_str(&outcome);
        assert!(s.contains("\"error\""), "{s}");
        assert!(s.contains("requires a `name`"), "{s}");
    }

    #[test]
    fn tools_call_non_string_name_is_a_typed_error() {
        let params = serde_json::json!({"name": 42});
        let outcome = dispatch(parsed("tools/call", Some(8), params), &caps(), &ctx());
        let s = dispatch_str(&outcome);
        assert!(s.contains("\"error\""), "{s}");
        assert!(s.contains("must be a string"), "{s}");
    }

    #[test]
    fn tools_call_non_object_params_is_a_typed_error() {
        let outcome = dispatch(
            parsed("tools/call", Some(9), Value::String("not an object".into())),
            &caps(),
            &ctx(),
        );
        let s = dispatch_str(&outcome);
        assert!(s.contains("\"error\""), "{s}");
        assert!(s.contains("must be an object"), "{s}");
    }

    #[test]
    fn tools_call_oversized_name_is_a_typed_error_not_a_panic() {
        let huge = "x".repeat(MAX_TOOL_NAME_LEN + 1);
        let params = serde_json::json!({"name": huge});
        let outcome = dispatch(parsed("tools/call", Some(10), params), &caps(), &ctx());
        let s = dispatch_str(&outcome);
        assert!(s.contains("\"error\""), "{s}");
        assert!(s.contains("too large"), "{s}");
    }

    #[test]
    fn unknown_method_is_a_typed_error() {
        let outcome = dispatch(parsed("frobnicate", Some(11), Value::Null), &caps(), &ctx());
        let s = dispatch_str(&outcome);
        assert!(s.contains("\"error\""), "{s}");
        assert!(s.contains("unknown JSON-RPC method"), "{s}");
        assert!(s.contains("-32601"), "{s}");
    }

    #[test]
    fn notification_without_id_is_skipped() {
        let outcome = dispatch(parsed("initialized", None, Value::Null), &caps(), &ctx());
        assert!(matches!(outcome, DispatchOutcome::Skip));
    }

    #[test]
    fn tools_call_without_arguments_defaults_to_empty_object() {
        // `application_info` ignores arguments; calling without `arguments`
        // must still succeed (defaults to an empty object).
        let params = serde_json::json!({"name": "application_info"});
        let outcome = dispatch(parsed("tools/call", Some(12), params), &caps(), &ctx());
        let s = dispatch_str(&outcome);
        assert!(s.contains("\"result\""), "{s}");
    }
}