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;
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)
}
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) => {
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(())
}
fn handle_line(
line: &str,
capabilities: &CapabilitySet,
context: &ToolContext,
) -> Option<Response> {
let request: Request = match serde_json::from_str(line) {
Ok(r) => r,
Err(_) => {
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) => {
id.map(|id| Response::error(id.to_response(), err))
}
}
}
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() {
let out = run_lines(&[r#"{"jsonrpc":"2.0","id":6,"method":42,"params":{}}"#]);
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() {
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\""));
}
}