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};
pub(crate) const PROTOCOL_VERSION: &str = "2025-06-18";
const SERVER_NAME: &str = "arcature-mcp";
pub(crate) enum DispatchOutcome {
Respond(Response),
Skip,
}
pub(crate) fn dispatch(
request: ParsedRequest,
capabilities: &CapabilitySet,
context: &ToolContext,
) -> DispatchOutcome {
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" => {
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),
)),
}
}
fn initialize(id: ResponseId, _params: Value) -> Response {
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)
}
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 }))
}
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),
}
}
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",
});
}
};
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(),
});
}
let tool = find(name).ok_or_else(|| McpError::UnknownTool(name.to_owned()))?;
capabilities.check(tool.required_capabilities)?;
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);
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() {
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}");
}
}