* feat(tools/mcp): add MCP subsystem tools layer with multi-transport client Introduces a new MCP (Model Context Protocol) subsystem to the tools layer, providing a multi-transport client implementation (stdio, HTTP, SSE) that allows ZeroClaw agents to connect to external MCP servers and register their exposed tools into the runtime tool registry. New files: - src/tools/mcp_client.rs: McpRegistry — lifecycle manager for MCP server connections - src/tools/mcp_protocol.rs: protocol types (request/response/notifications) - src/tools/mcp_tool.rs: McpToolWrapper — bridges MCP tools to ZeroClaw Tool trait - src/tools/mcp_transport.rs: transport abstraction (Stdio, Http, Sse) Wiring changes: - src/tools/mod.rs: pub mod + pub use for new MCP modules - src/config/schema.rs: McpTransport, McpServerConfig, McpConfig types; mcp field on Config; validate_mcp_config; mcp unit tests - src/config/mod.rs: re-exports McpConfig, McpServerConfig, McpTransport - src/channels/mod.rs: MCP server init block in start_channels() - src/agent/loop_.rs: MCP registry init in run() and process_message() - src/onboard/wizard.rs: mcp: McpConfig::default() in both wizard constructors * fix(tools/mcp): inject MCP tools after built-in tool filter, not before MCP servers are user-declared external integrations. The built-in agent.allowed_tools / agent.denied_tools filter (filter_primary_agent_tools_or_fail) governs built-in tool governance only. Injecting MCP tools before that filter would silently drop all MCP tools when a restrictive allowlist is configured. Add ordering comments at both call sites (run() CLI path and process_message() path) to make this contract explicit for reviewers and future merges. Identified via: shady831213/zeroclaw-agent-mcp@3f90b78 * fix(tools/mcp): strip approved field from MCP tool args before forwarding ZeroClaw's security model injects `approved: bool` into built-in tool args for supervised-mode confirmation. MCP servers have no knowledge of this field and reject calls that include it as an unexpected parameter. Strip `approved` from object-typed args in McpToolWrapper::execute() before forwarding to the MCP server. Non-object args pass through unchanged (no silent conversion or rejection). Add two unit tests: - execute_strips_approved_field_from_object_args: verifies removal - execute_handles_non_object_args_without_panic: verifies non-object shapes are not broken by the stripping logic Identified via: shady831213/zeroclaw-agent-mcp@c68be01 --------- Co-authored-by: argenis de la rosa <theonlyhennygod@gmail.com>
232 lines
8.1 KiB
Rust
232 lines
8.1 KiB
Rust
//! MCP (Model Context Protocol) JSON-RPC 2.0 protocol types.
|
|
//! Protocol version: 2024-11-05
|
|
//! Adapted from ops-mcp-server/src/protocol.rs for client use.
|
|
//! Both Serialize and Deserialize are derived — the client both sends (Serialize)
|
|
//! and receives (Deserialize) JSON-RPC messages.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
pub const JSONRPC_VERSION: &str = "2.0";
|
|
pub const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
|
|
|
|
// Standard JSON-RPC 2.0 error codes
|
|
pub const PARSE_ERROR: i32 = -32700;
|
|
pub const INVALID_REQUEST: i32 = -32600;
|
|
pub const METHOD_NOT_FOUND: i32 = -32601;
|
|
pub const INVALID_PARAMS: i32 = -32602;
|
|
pub const INTERNAL_ERROR: i32 = -32603;
|
|
|
|
/// Outbound JSON-RPC request (client → MCP server).
|
|
/// Used for both method calls (with id) and notifications (id = None).
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct JsonRpcRequest {
|
|
pub jsonrpc: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub id: Option<serde_json::Value>,
|
|
pub method: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub params: Option<serde_json::Value>,
|
|
}
|
|
|
|
impl JsonRpcRequest {
|
|
/// Create a method call request with a numeric id.
|
|
pub fn new(id: u64, method: impl Into<String>, params: serde_json::Value) -> Self {
|
|
Self {
|
|
jsonrpc: JSONRPC_VERSION.to_string(),
|
|
id: Some(serde_json::Value::Number(id.into())),
|
|
method: method.into(),
|
|
params: Some(params),
|
|
}
|
|
}
|
|
|
|
/// Create a notification — no id, no response expected from server.
|
|
pub fn notification(method: impl Into<String>, params: serde_json::Value) -> Self {
|
|
Self {
|
|
jsonrpc: JSONRPC_VERSION.to_string(),
|
|
id: None,
|
|
method: method.into(),
|
|
params: Some(params),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Inbound JSON-RPC response (MCP server → client).
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct JsonRpcResponse {
|
|
pub jsonrpc: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub id: Option<serde_json::Value>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub result: Option<serde_json::Value>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub error: Option<JsonRpcError>,
|
|
}
|
|
|
|
/// JSON-RPC error object embedded in a response.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct JsonRpcError {
|
|
pub code: i32,
|
|
pub message: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub data: Option<serde_json::Value>,
|
|
}
|
|
|
|
/// A tool advertised by an MCP server (from `tools/list` response).
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct McpToolDef {
|
|
pub name: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub description: Option<String>,
|
|
#[serde(rename = "inputSchema")]
|
|
pub input_schema: serde_json::Value,
|
|
}
|
|
|
|
/// Expected shape of the `tools/list` result payload.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct McpToolsListResult {
|
|
pub tools: Vec<McpToolDef>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn request_serializes_with_id() {
|
|
let req = JsonRpcRequest::new(1, "tools/list", serde_json::json!({}));
|
|
let s = serde_json::to_string(&req).unwrap();
|
|
assert!(s.contains("\"id\":1"));
|
|
assert!(s.contains("\"method\":\"tools/list\""));
|
|
assert!(s.contains("\"jsonrpc\":\"2.0\""));
|
|
}
|
|
|
|
#[test]
|
|
fn notification_omits_id() {
|
|
let notif =
|
|
JsonRpcRequest::notification("notifications/initialized", serde_json::json!({}));
|
|
let s = serde_json::to_string(¬if).unwrap();
|
|
assert!(!s.contains("\"id\""));
|
|
}
|
|
|
|
#[test]
|
|
fn response_deserializes() {
|
|
let json = r#"{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}"#;
|
|
let resp: JsonRpcResponse = serde_json::from_str(json).unwrap();
|
|
assert!(resp.result.is_some());
|
|
assert!(resp.error.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn tool_def_deserializes_input_schema() {
|
|
let json = r#"{"name":"read_file","description":"Read a file","inputSchema":{"type":"object","properties":{"path":{"type":"string"}}}}"#;
|
|
let def: McpToolDef = serde_json::from_str(json).unwrap();
|
|
assert_eq!(def.name, "read_file");
|
|
assert!(def.input_schema.is_object());
|
|
}
|
|
|
|
// ── Additional protocol coverage ─────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn request_params_included_when_present() {
|
|
let req = JsonRpcRequest::new(42, "ping", serde_json::json!({}));
|
|
let s = serde_json::to_string(&req).unwrap();
|
|
assert!(s.contains("\"params\""));
|
|
assert_eq!(req.id, Some(serde_json::json!(42)));
|
|
assert_eq!(req.method, "ping");
|
|
assert_eq!(req.jsonrpc, JSONRPC_VERSION);
|
|
}
|
|
|
|
#[test]
|
|
fn notification_has_no_id_field_in_serialized_json() {
|
|
let n = JsonRpcRequest::notification("tools/list", serde_json::json!({}));
|
|
assert!(n.id.is_none());
|
|
let s = serde_json::to_string(&n).unwrap();
|
|
assert!(!s.contains("\"id\""));
|
|
}
|
|
|
|
#[test]
|
|
fn error_response_deserializes_with_code_and_message() {
|
|
let json =
|
|
r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found"}}"#;
|
|
let resp: JsonRpcResponse = serde_json::from_str(json).unwrap();
|
|
assert!(resp.error.is_some());
|
|
let err = resp.error.unwrap();
|
|
assert_eq!(err.code, METHOD_NOT_FOUND);
|
|
assert_eq!(err.message, "Method not found");
|
|
assert!(err.data.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn error_response_with_data_field() {
|
|
let json = r#"{"jsonrpc":"2.0","id":2,"error":{"code":-32602,"message":"Invalid params","data":{"param":"foo"}}}"#;
|
|
let resp: JsonRpcResponse = serde_json::from_str(json).unwrap();
|
|
let err = resp.error.unwrap();
|
|
assert_eq!(err.code, INVALID_PARAMS);
|
|
assert!(err.data.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn jsonrpc_error_codes_match_spec() {
|
|
assert_eq!(PARSE_ERROR, -32700);
|
|
assert_eq!(INVALID_REQUEST, -32600);
|
|
assert_eq!(METHOD_NOT_FOUND, -32601);
|
|
assert_eq!(INVALID_PARAMS, -32602);
|
|
assert_eq!(INTERNAL_ERROR, -32603);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_protocol_version_constant_is_correct() {
|
|
assert_eq!(MCP_PROTOCOL_VERSION, "2024-11-05");
|
|
}
|
|
|
|
#[test]
|
|
fn tool_def_description_is_optional() {
|
|
let json = r#"{"name":"no_desc","inputSchema":{}}"#;
|
|
let def: McpToolDef = serde_json::from_str(json).unwrap();
|
|
assert_eq!(def.name, "no_desc");
|
|
assert!(def.description.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn tools_list_result_deserializes_multiple_tools() {
|
|
let json = r#"{"tools":[{"name":"a","inputSchema":{}},{"name":"b","description":"B tool","inputSchema":{"type":"object"}}]}"#;
|
|
let result: McpToolsListResult = serde_json::from_str(json).unwrap();
|
|
assert_eq!(result.tools.len(), 2);
|
|
assert_eq!(result.tools[0].name, "a");
|
|
assert_eq!(result.tools[1].name, "b");
|
|
assert!(result.tools[1].description.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn response_round_trip_via_serde() {
|
|
let original = JsonRpcResponse {
|
|
jsonrpc: JSONRPC_VERSION.to_string(),
|
|
id: Some(serde_json::json!(99)),
|
|
result: Some(serde_json::json!({"answer": 42})),
|
|
error: None,
|
|
};
|
|
let serialized = serde_json::to_string(&original).unwrap();
|
|
let deserialized: JsonRpcResponse = serde_json::from_str(&serialized).unwrap();
|
|
assert_eq!(deserialized.id, original.id);
|
|
assert_eq!(deserialized.result, original.result);
|
|
assert!(deserialized.error.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn request_new_produces_numeric_id() {
|
|
let req = JsonRpcRequest::new(
|
|
7,
|
|
"tools/call",
|
|
serde_json::json!({"name":"foo","arguments":{}}),
|
|
);
|
|
assert_eq!(req.id, Some(serde_json::Value::Number(7u64.into())));
|
|
}
|
|
|
|
#[test]
|
|
fn tools_list_result_with_empty_tools_array() {
|
|
let json = r#"{"tools":[]}"#;
|
|
let result: McpToolsListResult = serde_json::from_str(json).unwrap();
|
|
assert_eq!(result.tools.len(), 0);
|
|
}
|
|
}
|