diff --git a/Cargo.toml b/Cargo.toml index 62bd12b..0fe85dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ tokio = { version = "1", features = ["full"] } reqwest = { version = "0.12", features = ["json"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +async-trait = "0.1" eyre = "0.6" colored = "2" hex = "0.4" diff --git a/README.md b/README.md index 232507c..b5fb3d0 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ arbitrum-cli block latest --human | `watch blocks` | Stream new blocks (polling) | | `exec --params '[...]'` | Generic RPC passthrough | | `agent-deposit
--action ...` | Create Protocol AgentDeposit — balance, deposit, withdraw, registered | -| `mcp` | Start MCP server for AI agents | +| `mcp` | Start MCP server (stdio) for AI agents | | `info` | List supported Arbitrum chains | ## Agent mode @@ -104,18 +104,42 @@ Phase 1 of Create Protocol is live on Sepolia with Arbitrum One redeployment imm ## MCP server -Expose arbitrum-cli as a [Model Context Protocol](https://modelcontextprotocol.io) server so Claude, Cursor, or any MCP-compatible agent can call Arbitrum directly. +Expose arbitrum-cli as a [Model Context Protocol](https://modelcontextprotocol.io) server so Claude, Cursor, or any MCP-compatible agent can call Arbitrum directly. The server speaks JSON-RPC 2.0 over **stdio** — the transport MCP hosts use when they launch a tool binary. ```bash -arbitrum-cli mcp --bind 127.0.0.1:3456 +arbitrum-cli mcp ``` -Tools exposed: +It handles `initialize`, `tools/list`, `tools/call`, and `ping`. Tools exposed: - `arbitrum.block` · `arbitrum.tx` · `arbitrum.balance` · `arbitrum.token` - `arbitrum.call` · `arbitrum.gas` · `arbitrum.exec` -*(MCP integration is stubbed in v0.1 — full stdio + SSE support in v0.2.)* +Each tool advertises a JSON Schema for its arguments and returns its result as both a text `content` block and `structuredContent`. Pass a custom endpoint with `--rpc ` or the `ARBITRUM_RPC_URL` env var. + +Wire it into an MCP host (e.g. Claude Desktop's `claude_desktop_config.json`): + +```json +{ + "mcpServers": { + "arbitrum": { + "command": "arbitrum-cli", + "args": ["mcp"], + "env": { "ARBITRUM_RPC_URL": "https://arb1.arbitrum.io/rpc" } + } + } +} +``` + +You can also drive it by hand — one JSON-RPC request per line on stdin: + +```bash +printf '%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \ + '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"arbitrum.gas","arguments":{}}}' \ + | arbitrum-cli mcp +``` ## Configuration diff --git a/src/commands.rs b/src/commands.rs index 9f19727..6894987 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -380,15 +380,12 @@ pub async fn agent_deposit( Ok(()) } -// ── mcp (stub) ── -pub async fn mcp(_rpc: &str, bind: &str) -> Result<()> { - // MCP server stub — production version would expose tools via stdio or SSE - // following the Model Context Protocol spec. - eprintln!("MCP server mode — stub implementation"); - eprintln!("Bind: {bind}"); - eprintln!("Tools exposed: block, tx, balance, token, call, gas, exec"); - eprintln!(); - eprintln!("Full MCP implementation coming — this stub validates the tool shape."); - eprintln!("See: https://modelcontextprotocol.io"); - Ok(()) +// ── mcp ── +// +// Run a real Model Context Protocol server over stdio. MCP hosts (Claude +// Desktop, Cursor, …) spawn this binary and speak JSON-RPC 2.0 on +// stdin/stdout. The protocol surface (initialize / tools/list / tools/call) +// and tool execution live in `crate::mcp`; this is just the entry point. +pub async fn mcp(rpc: &str) -> Result<()> { + crate::mcp::serve_stdio(rpc).await } diff --git a/src/main.rs b/src/main.rs index cbb1908..1ad6d37 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use clap::{CommandFactory, Parser, Subcommand}; mod agent_deposit; mod commands; +mod mcp; mod output; mod rpc; @@ -83,12 +84,12 @@ enum Commands { params: String, }, - /// Start an MCP server exposing arbitrum-cli as tools for AI agents - Mcp { - /// Bind address - #[arg(long, default_value = "127.0.0.1:3456")] - bind: String, - }, + /// Start an MCP server (over stdio) exposing arbitrum-cli as tools for AI agents + /// + /// Speaks the Model Context Protocol over JSON-RPC 2.0 on stdin/stdout — + /// the transport MCP hosts (Claude Desktop, Cursor, …) use to launch tool + /// servers. Handles initialize, tools/list, and tools/call. + Mcp, /// Interact with Create Protocol AgentDeposit on Arbitrum /// @@ -143,7 +144,7 @@ async fn main() -> eyre::Result<()> { Commands::Exec { method, params } => { commands::exec(&rpc_url, &method, ¶ms, out_mode).await? } - Commands::Mcp { bind } => commands::mcp(&rpc_url, &bind).await?, + Commands::Mcp => commands::mcp(&rpc_url).await?, Commands::AgentDeposit { address, action, diff --git a/src/mcp.rs b/src/mcp.rs new file mode 100644 index 0000000..cb2f45f --- /dev/null +++ b/src/mcp.rs @@ -0,0 +1,743 @@ +//! Model Context Protocol (MCP) server over stdio. +//! +//! Speaks the [Model Context Protocol] JSON-RPC 2.0 wire format on stdin/stdout +//! — the transport Claude Desktop, Cursor, and other MCP hosts use when they +//! spawn a tool binary. This turns arbitrum-cli into a first-class tool server: +//! an LLM can `initialize`, list tools, and call them to read Arbitrum state. +//! +//! Layering for testability: +//! - [`tool_registry`] / [`tools_list_result`] / [`initialize_result`] are pure +//! and synchronous, so the protocol surface can be asserted without I/O. +//! - [`dispatch`] routes one parsed JSON-RPC request to a response. Tool +//! execution is delegated to an injected [`RpcCaller`], so a full +//! `tools/call` round-trip can be exercised against a mock RPC with no +//! network. +//! - [`serve_stdio`] is the thin runtime: read a line, dispatch, write a line. +//! +//! [Model Context Protocol]: https://modelcontextprotocol.io + +use crate::rpc::{hex_to_u64, rpc_call, wei_hex_to_eth}; +use async_trait::async_trait; +use eyre::Result; +use serde_json::{json, Value}; + +/// MCP protocol version this server implements (date-based, per spec). +pub const PROTOCOL_VERSION: &str = "2024-11-05"; +/// JSON-RPC error code for an unknown method. +pub const METHOD_NOT_FOUND: i64 = -32601; +/// JSON-RPC error code for malformed parameters. +pub const INVALID_PARAMS: i64 = -32602; + +/// A single MCP tool: stable name, human description, and a JSON Schema for its +/// arguments. The schema doubles as documentation for the LLM and as the +/// validation contract advertised in `tools/list`. +pub struct ToolDef { + pub name: &'static str, + pub description: &'static str, + pub schema: fn() -> Value, +} + +/// Helper: a JSON Schema object with the given properties and required keys. +fn object_schema(properties: Value, required: &[&str]) -> Value { + json!({ + "type": "object", + "properties": properties, + "required": required, + }) +} + +fn block_schema() -> Value { + object_schema( + json!({ + "block": { + "type": "string", + "description": "Block number (decimal), or \"latest\", \"earliest\", \"pending\".", + } + }), + &["block"], + ) +} + +fn tx_schema() -> Value { + object_schema( + json!({ + "hash": { + "type": "string", + "description": "Transaction hash (0x-prefixed, 32 bytes).", + } + }), + &["hash"], + ) +} + +fn balance_schema() -> Value { + object_schema( + json!({ + "address": { + "type": "string", + "description": "Wallet address (0x-prefixed, 20 bytes).", + } + }), + &["address"], + ) +} + +fn token_schema() -> Value { + object_schema( + json!({ + "token": { + "type": "string", + "description": "ERC-20 token contract address.", + }, + "address": { + "type": "string", + "description": "Wallet address to query the balance of.", + } + }), + &["token", "address"], + ) +} + +fn call_schema() -> Value { + object_schema( + json!({ + "to": { + "type": "string", + "description": "Contract address to call (eth_call).", + }, + "data": { + "type": "string", + "description": "ABI-encoded calldata (0x-prefixed hex).", + } + }), + &["to", "data"], + ) +} + +fn gas_schema() -> Value { + object_schema(json!({}), &[]) +} + +fn exec_schema() -> Value { + object_schema( + json!({ + "method": { + "type": "string", + "description": "JSON-RPC method name, e.g. eth_blockNumber.", + }, + "params": { + "type": "array", + "description": "Positional params for the RPC call. Defaults to [].", + } + }), + &["method"], + ) +} + +/// The full set of tools this server exposes. The names are namespaced +/// (`arbitrum.*`) so they don't collide with other tools an MCP host has +/// loaded. +pub fn tool_registry() -> Vec { + vec![ + ToolDef { + name: "arbitrum.block", + description: "Get an Arbitrum block by number or tag (latest/earliest/pending).", + schema: block_schema, + }, + ToolDef { + name: "arbitrum.tx", + description: "Look up a transaction by hash.", + schema: tx_schema, + }, + ToolDef { + name: "arbitrum.balance", + description: "Get the native ETH balance of an address (wei + ETH).", + schema: balance_schema, + }, + ToolDef { + name: "arbitrum.token", + description: "Get an ERC-20 token balance for an address (raw + decimal).", + schema: token_schema, + }, + ToolDef { + name: "arbitrum.call", + description: "Read from a contract via eth_call with raw calldata.", + schema: call_schema, + }, + ToolDef { + name: "arbitrum.gas", + description: "Get the current gas price (wei + gwei) and block number.", + schema: gas_schema, + }, + ToolDef { + name: "arbitrum.exec", + description: "Execute an arbitrary JSON-RPC method against the Arbitrum node.", + schema: exec_schema, + }, + ] +} + +/// Build the `tools/list` result body: `{ "tools": [ {name, description, +/// inputSchema}, ... ] }` per the MCP spec. +pub fn tools_list_result() -> Value { + let tools: Vec = tool_registry() + .iter() + .map(|tool| { + json!({ + "name": tool.name, + "description": tool.description, + "inputSchema": (tool.schema)(), + }) + }) + .collect(); + json!({ "tools": tools }) +} + +/// Build the `initialize` result: protocol version, server capabilities, and +/// server identity. +pub fn initialize_result() -> Value { + json!({ + "protocolVersion": PROTOCOL_VERSION, + "capabilities": { + "tools": { "listChanged": false } + }, + "serverInfo": { + "name": "arbitrum-cli", + "version": env!("CARGO_PKG_VERSION"), + }, + }) +} + +/// Abstraction over "make a JSON-RPC call to the node". The live server uses +/// [`LiveRpc`]; tests inject a mock so a `tools/call` round-trip needs no +/// network. +#[async_trait] +pub trait RpcCaller: Send + Sync { + async fn call(&self, method: &str, params: Value) -> Result; +} + +/// Production [`RpcCaller`] — forwards to the real HTTP JSON-RPC client. +pub struct LiveRpc { + pub url: String, +} + +#[async_trait] +impl RpcCaller for LiveRpc { + async fn call(&self, method: &str, params: Value) -> Result { + rpc_call(&self.url, method, params).await + } +} + +/// Pull a required string argument out of a `tools/call` arguments object. +fn req_str<'a>(args: &'a Value, key: &str) -> Result<&'a str> { + args.get(key) + .and_then(|v| v.as_str()) + .ok_or_else(|| eyre::eyre!("missing required string argument '{key}'")) +} + +/// Execute one tool by name. Returns the tool's structured JSON result, which +/// the caller wraps into an MCP `content` block. Network access happens only +/// through `rpc`, so this is fully exercisable with a mock. +pub async fn execute_tool(rpc: &dyn RpcCaller, name: &str, args: &Value) -> Result { + match name { + "arbitrum.block" => { + let block = req_str(args, "block")?; + let block_param = if matches!(block, "latest" | "earliest" | "pending") { + json!(block) + } else { + let n: u64 = block + .parse() + .map_err(|_| eyre::eyre!("invalid block number: {block}"))?; + json!(format!("0x{n:x}")) + }; + let mut result = rpc + .call("eth_getBlockByNumber", json!([block_param, false])) + .await?; + if let Some(obj) = result.as_object_mut() { + if let Some(n) = obj + .get("number") + .and_then(|v| v.as_str()) + .and_then(|s| hex_to_u64(s).ok()) + { + obj.insert("number_decimal".to_string(), json!(n)); + } + if let Some(t) = obj + .get("timestamp") + .and_then(|v| v.as_str()) + .and_then(|s| hex_to_u64(s).ok()) + { + obj.insert("timestamp_decimal".to_string(), json!(t)); + } + } + Ok(result) + } + "arbitrum.tx" => { + let hash = req_str(args, "hash")?; + let result = rpc.call("eth_getTransactionByHash", json!([hash])).await?; + if result.is_null() { + return Err(eyre::eyre!("transaction not found: {hash}")); + } + Ok(result) + } + "arbitrum.balance" => { + let address = req_str(args, "address")?; + let result = rpc + .call("eth_getBalance", json!([address, "latest"])) + .await?; + let wei_hex = result.as_str().unwrap_or("0x0"); + Ok(json!({ + "address": address, + "balance_wei": wei_hex, + "balance_eth": wei_hex_to_eth(wei_hex)?, + })) + } + "arbitrum.token" => { + let token = req_str(args, "token")?; + let address = req_str(args, "address")?.trim_start_matches("0x"); + let padded = format!("{address:0>64}"); + let data = format!("0x70a08231{padded}"); + let result = rpc + .call("eth_call", json!([{"to": token, "data": data}, "latest"])) + .await?; + let raw = result.as_str().unwrap_or("0x0"); + let decimals = rpc + .call( + "eth_call", + json!([{"to": token, "data": "0x313ce567"}, "latest"]), + ) + .await + .ok() + .and_then(|v| v.as_str().and_then(|s| hex_to_u64(s).ok())) + .unwrap_or(18); + let balance_raw = u128::from_str_radix(raw.trim_start_matches("0x"), 16).unwrap_or(0); + let balance_human = balance_raw as f64 / 10f64.powi(decimals as i32); + Ok(json!({ + "token": token, + "address": address, + "decimals": decimals, + "balance_raw": raw, + "balance": balance_human, + })) + } + "arbitrum.call" => { + let to = req_str(args, "to")?; + let data = req_str(args, "data")?; + let result = rpc + .call("eth_call", json!([{"to": to, "data": data}, "latest"])) + .await?; + Ok(json!({ "to": to, "data": data, "result": result })) + } + "arbitrum.gas" => { + let gas_price = rpc.call("eth_gasPrice", json!([])).await?; + let block_num = rpc.call("eth_blockNumber", json!([])).await?; + let gas_hex = gas_price.as_str().unwrap_or("0x0"); + let gwei = u128::from_str_radix(gas_hex.trim_start_matches("0x"), 16).unwrap_or(0) + as f64 + / 1e9; + Ok(json!({ + "gas_price_wei": gas_hex, + "gas_price_gwei": gwei, + "block_number": block_num, + })) + } + "arbitrum.exec" => { + let method = req_str(args, "method")?; + let params = args.get("params").cloned().unwrap_or_else(|| json!([])); + if !params.is_array() { + return Err(eyre::eyre!("'params' must be a JSON array")); + } + let result = rpc.call(method, params).await?; + Ok(json!({ "method": method, "result": result })) + } + other => Err(eyre::eyre!("unknown tool '{other}'")), + } +} + +/// Build a JSON-RPC 2.0 success envelope. +fn rpc_result(id: Value, result: Value) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "result": result }) +} + +/// Build a JSON-RPC 2.0 error envelope. +fn rpc_error(id: Value, code: i64, message: impl Into) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": code, "message": message.into() }, + }) +} + +/// Wrap a tool's JSON output into the MCP `tools/call` result shape: a single +/// text `content` block carrying the JSON, plus the parsed object as +/// `structuredContent` for hosts that consume it directly. +fn tool_call_result(value: Value) -> Value { + let text = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string()); + json!({ + "content": [ { "type": "text", "text": text } ], + "structuredContent": value, + "isError": false, + }) +} + +/// MCP `tools/call` error result: per spec, tool execution failures are +/// reported as a successful JSON-RPC response with `isError: true` so the model +/// can read and react to the message. +fn tool_call_error(message: impl Into) -> Value { + json!({ + "content": [ { "type": "text", "text": message.into() } ], + "isError": true, + }) +} + +/// Route one parsed JSON-RPC request to a response value. +/// +/// Returns `None` for notifications (requests with no `id`, e.g. +/// `notifications/initialized`), which per JSON-RPC must not be answered. +pub async fn dispatch(rpc: &dyn RpcCaller, request: &Value) -> Option { + let method = request.get("method").and_then(|m| m.as_str()).unwrap_or(""); + + // No id => notification. Acknowledge nothing. + let id = request.get("id").cloned()?; + + match method { + "initialize" => Some(rpc_result(id, initialize_result())), + "ping" => Some(rpc_result(id, json!({}))), + "tools/list" => Some(rpc_result(id, tools_list_result())), + "tools/call" => { + let params = request.get("params").cloned().unwrap_or_else(|| json!({})); + let name = match params.get("name").and_then(|n| n.as_str()) { + Some(n) => n, + None => { + return Some(rpc_error( + id, + INVALID_PARAMS, + "tools/call requires a 'name' parameter", + )) + } + }; + let empty = json!({}); + let args = params.get("arguments").unwrap_or(&empty); + match execute_tool(rpc, name, args).await { + Ok(value) => Some(rpc_result(id, tool_call_result(value))), + // Tool-level failure: report as isError content, not a + // protocol error, so the model sees the message. + Err(err) => Some(rpc_result(id, tool_call_error(err.to_string()))), + } + } + other => Some(rpc_error( + id, + METHOD_NOT_FOUND, + format!("unknown method '{other}'"), + )), + } +} + +/// Drive the MCP protocol over any line-oriented transport: read newline- +/// delimited JSON-RPC requests from `reader`, dispatch each, and write +/// newline-delimited JSON-RPC responses to `writer`, flushing after each so a +/// host sees replies without buffering. Notifications produce no output; +/// malformed JSON yields a parse-error response with a null id; EOF ends the +/// loop. +/// +/// Generic over the byte streams so it runs against real stdin/stdout in +/// production and in-memory buffers in tests. +pub async fn run_loop(rpc: &dyn RpcCaller, reader: R, mut writer: W) -> Result<()> +where + R: tokio::io::AsyncBufRead + Unpin, + W: tokio::io::AsyncWrite + Unpin, +{ + use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; + + let mut lines = reader.lines(); + while let Some(line) = lines.next_line().await? { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let response = match serde_json::from_str::(trimmed) { + Ok(request) => dispatch(rpc, &request).await, + Err(err) => Some(rpc_error( + Value::Null, + INVALID_PARAMS, + format!("parse error: {err}"), + )), + }; + if let Some(response) = response { + let mut bytes = serde_json::to_vec(&response)?; + bytes.push(b'\n'); + writer.write_all(&bytes).await?; + writer.flush().await?; + } + } + Ok(()) +} + +/// Run the MCP server over real stdin/stdout. +pub async fn serve_stdio(rpc_url: &str) -> Result<()> { + use tokio::io::BufReader; + + eprintln!("arbitrum-cli MCP server (protocol {PROTOCOL_VERSION}) on stdio — RPC: {rpc_url}"); + + let caller = LiveRpc { + url: rpc_url.to_string(), + }; + let reader = BufReader::new(tokio::io::stdin()); + let writer = tokio::io::stdout(); + run_loop(&caller, reader, writer).await +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::sync::Mutex; + + /// In-memory RPC mock: returns canned results keyed by method name, and + /// records the calls it received so tests can assert on dispatch. + struct MockRpc { + responses: HashMap, + calls: Mutex>, + } + + impl MockRpc { + fn new(pairs: &[(&str, Value)]) -> Self { + let responses = pairs + .iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect(); + MockRpc { + responses, + calls: Mutex::new(Vec::new()), + } + } + } + + #[async_trait] + impl RpcCaller for MockRpc { + async fn call(&self, method: &str, params: Value) -> Result { + self.calls + .lock() + .unwrap() + .push((method.to_string(), params)); + self.responses + .get(method) + .cloned() + .ok_or_else(|| eyre::eyre!("mock has no response for {method}")) + } + } + + fn no_rpc() -> MockRpc { + MockRpc::new(&[]) + } + + #[tokio::test] + async fn initialize_reports_protocol_and_server_info() { + let req = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { "protocolVersion": "2024-11-05", "capabilities": {} } + }); + let resp = dispatch(&no_rpc(), &req).await.expect("response"); + assert_eq!(resp["jsonrpc"], "2.0"); + assert_eq!(resp["id"], 1); + assert_eq!(resp["result"]["protocolVersion"], PROTOCOL_VERSION); + assert_eq!(resp["result"]["serverInfo"]["name"], "arbitrum-cli"); + // Capabilities must advertise tools so hosts know to call tools/list. + assert!(resp["result"]["capabilities"]["tools"].is_object()); + } + + #[tokio::test] + async fn tools_list_advertises_every_tool_with_schema() { + let req = json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }); + let resp = dispatch(&no_rpc(), &req).await.expect("response"); + let tools = resp["result"]["tools"].as_array().expect("tools array"); + + let expected: Vec<&str> = tool_registry().iter().map(|t| t.name).collect(); + let actual: Vec<&str> = tools + .iter() + .map(|t| t["name"].as_str().expect("tool name")) + .collect(); + assert_eq!(actual, expected); + assert_eq!(tools.len(), 7); + + // Every tool must carry a description and an object inputSchema. + for tool in tools { + assert!(tool["description"].as_str().is_some()); + assert_eq!(tool["inputSchema"]["type"], "object"); + } + } + + #[tokio::test] + async fn tools_call_round_trip_executes_and_returns_content() { + // Mock eth_getBalance so balance runs fully offline. + let mock = MockRpc::new(&[("eth_getBalance", json!("0xde0b6b3a7640000"))]); // 1 ETH + let req = json!({ + "jsonrpc": "2.0", + "id": 7, + "method": "tools/call", + "params": { + "name": "arbitrum.balance", + "arguments": { "address": "0x1111111111111111111111111111111111111111" } + } + }); + let resp = dispatch(&mock, &req).await.expect("response"); + + assert_eq!(resp["jsonrpc"], "2.0"); + assert_eq!(resp["id"], 7); + assert_eq!(resp["result"]["isError"], false); + + // The content block carries the tool JSON as text. + let text = resp["result"]["content"][0]["text"] + .as_str() + .expect("content text"); + let parsed: Value = serde_json::from_str(text).expect("content is json"); + assert_eq!(parsed["balance_wei"], "0xde0b6b3a7640000"); + assert_eq!(parsed["balance_eth"], 1.0); + + // structuredContent mirrors the parsed result. + assert_eq!(resp["result"]["structuredContent"]["balance_eth"], 1.0); + + // The dispatcher actually invoked the RPC with the right method/params. + let calls = mock.calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, "eth_getBalance"); + assert_eq!( + calls[0].1, + json!(["0x1111111111111111111111111111111111111111", "latest"]) + ); + } + + #[tokio::test] + async fn tools_call_block_decorates_decimal_fields() { + let mock = MockRpc::new(&[( + "eth_getBlockByNumber", + json!({ "number": "0x10", "timestamp": "0x6657a000" }), + )]); + let req = json!({ + "jsonrpc": "2.0", + "id": 8, + "method": "tools/call", + "params": { "name": "arbitrum.block", "arguments": { "block": "16" } } + }); + let resp = dispatch(&mock, &req).await.expect("response"); + assert_eq!(resp["result"]["structuredContent"]["number_decimal"], 16); + assert_eq!( + resp["result"]["structuredContent"]["timestamp_decimal"], + 0x6657a000 + ); + // Block "16" must be sent as hex 0x10. + let calls = mock.calls.lock().unwrap(); + assert_eq!(calls[0].1, json!(["0x10", false])); + } + + #[tokio::test] + async fn tools_call_unknown_tool_is_iserror_not_protocol_error() { + let req = json!({ + "jsonrpc": "2.0", + "id": 9, + "method": "tools/call", + "params": { "name": "arbitrum.nope", "arguments": {} } + }); + let resp = dispatch(&no_rpc(), &req).await.expect("response"); + // Per MCP, tool failures are isError content, not JSON-RPC errors. + assert!(resp.get("error").is_none()); + assert_eq!(resp["result"]["isError"], true); + assert!(resp["result"]["content"][0]["text"] + .as_str() + .unwrap() + .contains("unknown tool")); + } + + #[tokio::test] + async fn tools_call_missing_argument_reports_iserror() { + let req = json!({ + "jsonrpc": "2.0", + "id": 10, + "method": "tools/call", + "params": { "name": "arbitrum.balance", "arguments": {} } + }); + let resp = dispatch(&no_rpc(), &req).await.expect("response"); + assert_eq!(resp["result"]["isError"], true); + assert!(resp["result"]["content"][0]["text"] + .as_str() + .unwrap() + .contains("address")); + } + + #[tokio::test] + async fn unknown_method_is_method_not_found() { + let req = json!({ "jsonrpc": "2.0", "id": 11, "method": "frobnicate" }); + let resp = dispatch(&no_rpc(), &req).await.expect("response"); + assert_eq!(resp["error"]["code"], METHOD_NOT_FOUND); + } + + #[tokio::test] + async fn notification_without_id_gets_no_response() { + let req = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }); + assert!(dispatch(&no_rpc(), &req).await.is_none()); + } + + #[tokio::test] + async fn exec_tool_passes_method_and_params_through() { + let mock = MockRpc::new(&[("eth_blockNumber", json!("0x123"))]); + let req = json!({ + "jsonrpc": "2.0", + "id": 12, + "method": "tools/call", + "params": { + "name": "arbitrum.exec", + "arguments": { "method": "eth_blockNumber", "params": [] } + } + }); + let resp = dispatch(&mock, &req).await.expect("response"); + assert_eq!(resp["result"]["structuredContent"]["result"], "0x123"); + assert_eq!( + resp["result"]["structuredContent"]["method"], + "eth_blockNumber" + ); + } + + /// Run `run_loop` over an in-memory input string and collect the + /// newline-delimited response lines it writes. + async fn drive_loop(rpc: &dyn RpcCaller, input: &str) -> Vec { + use tokio::io::BufReader; + + let reader = BufReader::new(input.as_bytes()); + let mut output: Vec = Vec::new(); + run_loop(rpc, reader, &mut output).await.expect("loop ok"); + + String::from_utf8(output) + .expect("utf8") + .lines() + .filter(|l| !l.is_empty()) + .map(|l| serde_json::from_str(l).expect("response is json")) + .collect() + } + + #[tokio::test] + async fn run_loop_drives_requests_and_skips_notifications() { + // Two requests with a notification interleaved, then EOF. + let input = [ + json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize" }).to_string(), + json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }).to_string(), + json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }).to_string(), + ] + .join("\n"); + + let out = drive_loop(&no_rpc(), &input).await; + + // The notification produced no output: 2 requests => 2 responses. + assert_eq!(out.len(), 2); + assert_eq!(out[0]["result"]["protocolVersion"], PROTOCOL_VERSION); + assert!(out[1]["result"]["tools"].is_array()); + } + + #[tokio::test] + async fn run_loop_reports_parse_error_with_null_id() { + let out = drive_loop(&no_rpc(), "{ this is not json").await; + assert_eq!(out.len(), 1); + assert_eq!(out[0]["error"]["code"], INVALID_PARAMS); + assert!(out[0]["id"].is_null()); + } +}