arcature-cli 2026.1.1

Developer lifecycle CLI for Arcature applications.
Documentation
//! The MCP stdio server loop.
//!
//! Reads JSON-RPC 2.0 requests from stdin (one per line), dispatches them,
//! and writes responses to stdout (one per line) — the standard MCP stdio
//! transport. The loop is **sync**: the CLI binary is a sync process (no
//! tokio runtime in `main`), so the server uses blocking std I/O. It loads
//! the UAG once per session (file-preferred: reads `.arcature/app-manifest.json`
//! when present, shells out to `arcature-metadata` otherwise) and hands the
//! loaded [`ToolContext`] to every tool — no per-call reload (§19).
//!
//! # Hostile input must not panic (AGENTS.md §17)
//!
//! Every line is parsed defensively: malformed JSON, oversized fields, and
//! unknown methods map to a typed [`McpError`] and a JSON-RPC error
//! response, never a panic. A parse failure for a line with no id (or no
//! valid id) is logged to stderr and skipped — no response is emitted,
//! since there is no id to echo.
//!
//! # Clean shutdown
//!
//! EOF on stdin ends the loop cleanly; no hanging process. The integration
//! test asserts the process exits on a closed stdin.

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

use crate::commands::mcp::capability::CapabilitySet;
use crate::commands::mcp::dispatch::{DispatchOutcome, dispatch};
use crate::commands::mcp::error::McpError;
use crate::commands::mcp::registry::ToolContext;
use crate::commands::mcp::transport::{Request, Response, validate};
use crate::metadata::load_uag;
use crate::project;

/// Run the stdio MCP server loop with the granted capabilities. Loads the
/// UAG from the current project (file-preferred) and dispatches requests
/// until stdin closes.
pub(crate) fn run_stdio(capabilities: CapabilitySet) -> Result<(), McpError> {
    let project = project::discover().map_err(|err| {
        McpError::Schema(crate::metadata::SchemaError::ReadManifest {
            path: std::path::PathBuf::from("."),
            source: io::Error::other(err.to_string()),
        })
    })?;
    let uag = load_uag(&project)?;
    let context = ToolContext { uag };
    serve(io::stdin().lock(), io::stdout(), &capabilities, &context)
}

/// The pure loop, split out so the integration test can drive it over a
/// pipe: reads requests from `input` (one JSON object per line), writes
/// responses to `output` (one JSON object per line). Stops at EOF. Errors
/// are written to `output` as JSON-RPC error responses; parse failures with
/// no recoverable id are skipped.
pub(crate) fn serve<R: BufRead, W: Write>(
    input: R,
    mut output: W,
    capabilities: &CapabilitySet,
    context: &ToolContext,
) -> Result<(), McpError> {
    for line in input.lines() {
        let line = match line {
            Ok(line) => line,
            Err(err) => {
                // An I/O error reading a line is not a JSON-RPC error we can
                // echo (no id); log to stderr and stop.
                eprintln!("arc mcp: cannot read stdin: {err}");
                return Err(McpError::Schema(
                    crate::metadata::SchemaError::ReadManifest {
                        path: std::path::PathBuf::from("<stdin>"),
                        source: err,
                    },
                ));
            }
        };
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        let response = handle_line(trimmed, capabilities, context);
        if let Some(response) = response {
            let json = serde_json::to_string(&response).map_err(McpError::from)?;
            writeln!(output, "{json}").map_err(|err| {
                McpError::Schema(crate::metadata::SchemaError::ReadManifest {
                    path: std::path::PathBuf::from("<stdout>"),
                    source: err,
                })
            })?;
            output.flush().ok();
        }
    }
    Ok(())
}

/// Parse + validate + dispatch one line. Returns the response to emit, or
/// `None` for a notification (no response) or an unidentifiable parse
/// failure (no id to echo).
fn handle_line(
    line: &str,
    capabilities: &CapabilitySet,
    context: &ToolContext,
) -> Option<Response> {
    let request: Request = match serde_json::from_str(line) {
        Ok(r) => r,
        Err(_) => {
            // Try to recover an id so we can echo a parse error; if we cannot,
            // there is nothing to respond to. Hostile input, no panic.
            if let Some(id) = recover_id(line) {
                return Some(Response::error(
                    id,
                    McpError::InvalidArgument {
                        message: "malformed JSON-RPC request",
                    },
                ));
            }
            return None;
        }
    };
    let id = request.id.clone();
    match validate(request) {
        Ok(parsed) => {
            let outcome = dispatch(parsed, capabilities, context);
            match outcome {
                DispatchOutcome::Respond(response) => Some(response),
                DispatchOutcome::Skip => None,
            }
        }
        Err(err) => {
            // Validation failed (bad version, oversized method). Echo an error
            // if we have an id.
            id.map(|id| Response::error(id.to_response(), err))
        }
    }
}

/// Best-effort recovery of the `id` from a malformed JSON-RPC line, so a
/// parse error can still be echoed to the client. Returns `None` if the id
/// cannot be safely recovered. This is bounded and defensive: it does a
/// shallow scan, not a full re-parse.
fn recover_id(line: &str) -> Option<crate::commands::mcp::transport::ResponseId> {
    let value: serde_json::Value = serde_json::from_str(line).ok()?;
    let id = value.get("id")?;
    use crate::commands::mcp::transport::ResponseId;
    match id {
        serde_json::Value::Number(n) => {
            let i = n.as_i64()?;
            Some(ResponseId::Num(i))
        }
        serde_json::Value::String(s) => Some(ResponseId::Str(s.clone())),
        serde_json::Value::Null => Some(ResponseId::Null),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cli::McpOptions;
    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 run_lines(lines: &[&str]) -> Vec<String> {
        let input = lines.iter().map(|l| format!("{l}\n")).collect::<String>();
        let mut output = Vec::new();
        serve(
            io::Cursor::new(input.into_bytes()),
            &mut output,
            &caps(),
            &ctx(),
        )
        .expect("serve ok");
        String::from_utf8(output)
            .expect("utf8")
            .lines()
            .map(str::to_owned)
            .collect()
    }

    #[test]
    fn initialize_responds() {
        let out = run_lines(&[r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#]);
        assert_eq!(out.len(), 1);
        assert!(out[0].contains("\"protocol_version\":\"2025-06-18\""));
    }

    #[test]
    fn tools_list_responds() {
        let out = run_lines(&[r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#]);
        assert!(out[0].contains("application_info"));
        assert!(out[0].contains("docs_search"));
    }

    #[test]
    fn tools_call_dispatches() {
        let out = run_lines(&[
            r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"application_info"}}"#,
        ]);
        assert!(out[0].contains("\"result\""));
        assert!(out[0].contains("schema_version"));
    }

    #[test]
    fn notification_gets_no_response() {
        let out = run_lines(&[r#"{"jsonrpc":"2.0","method":"initialized","params":{}}"#]);
        assert!(out.is_empty(), "notifications get no response");
    }

    #[test]
    fn unknown_method_responds_error() {
        let out = run_lines(&[r#"{"jsonrpc":"2.0","id":4,"method":"nope","params":{}}"#]);
        assert!(out[0].contains("\"error\""));
        assert!(out[0].contains("-32601"));
    }

    #[test]
    fn unknown_tool_responds_error() {
        let out = run_lines(&[
            r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"nope"}}"#,
        ]);
        assert!(out[0].contains("unknown tool: nope"));
    }

    #[test]
    fn malformed_json_with_recoverable_id_responds_error() {
        // Valid JSON with a recognizable id but wrong shape for a Request.
        let out = run_lines(&[r#"{"jsonrpc":"2.0","id":6,"method":42,"params":{}}"#]);
        // `method` is non-string → deserialize fails → recover id → echo error.
        assert_eq!(out.len(), 1);
        assert!(out[0].contains("\"error\""));
        assert!(out[0].contains("\"id\":6"));
    }

    #[test]
    fn malformed_json_without_id_is_skipped() {
        let out = run_lines(&["{ not even valid json {"]);
        assert!(out.is_empty(), "no id to echo → no response, no panic");
    }

    #[test]
    fn empty_lines_are_skipped() {
        let out = run_lines(&[
            "",
            "   ",
            r#"{"jsonrpc":"2.0","id":7,"method":"ping","params":{}}"#,
        ]);
        assert_eq!(out.len(), 1);
        assert!(out[0].contains("\"result\""));
    }

    #[test]
    fn eof_ends_cleanly() {
        // No trailing newline on the last line still terminates.
        let input = r#"{"jsonrpc":"2.0","id":8,"method":"ping","params":{}}"#;
        let mut output = Vec::new();
        serve(
            io::Cursor::new(input.as_bytes().to_vec()),
            &mut output,
            &caps(),
            &ctx(),
        )
        .expect("ok");
        let s = String::from_utf8(output).expect("utf8");
        assert!(s.contains("\"result\""));
    }

    #[test]
    fn hostile_huge_method_is_a_typed_error_not_a_panic() {
        let huge = "x".repeat(crate::commands::mcp::transport::MAX_METHOD_LEN + 1);
        let json = format!(r#"{{"jsonrpc":"2.0","id":9,"method":"{huge}","params":{{}}}}"#);
        let out = run_lines(&[&json]);
        assert!(out[0].contains("too large"));
        assert!(out[0].contains("\"error\""));
    }
}