| 1 | //! JSON-RPC bridge to the MCP endpoint at `POST /api/v1/mcp`. |
| 2 | //! |
| 3 | //! Issues, pull requests, code search, and file reads have no REST surface on |
| 4 | //! sigit.si — they are served as MCP tools. Rather than wait for a second API, |
| 5 | //! the client speaks the same stateless JSON-RPC the agent does and decodes the |
| 6 | //! tool results into ordinary types. The transport is an implementation |
| 7 | //! detail; callers use [`crate::issues`], [`crate::pulls`], and |
| 8 | //! [`crate::code`]. |
| 9 | //! |
| 10 | //! The endpoint is stateless (see `Mcp::Server`), so there is no `initialize` |
| 11 | //! handshake to perform — a `tools/call` stands on its own. |
| 12 | |
| 13 | use { |
| 14 | crate::{ |
| 15 | client::Client, |
| 16 | error::{ApiError, Result}, |
| 17 | }, |
| 18 | serde::de::DeserializeOwned, |
| 19 | serde_json::{json, Value}, |
| 20 | std::sync::atomic::{AtomicU64, Ordering}, |
| 21 | tracing::debug, |
| 22 | }; |
| 23 | |
| 24 | /// JSON-RPC ids only need to be unique within a connection; a process-wide |
| 25 | /// counter is plenty and keeps `call_tool` callable through `&self`. |
| 26 | static NEXT_ID: AtomicU64 = AtomicU64::new(1); |
| 27 | |
| 28 | impl Client { |
| 29 | /// Invoke one MCP tool and return its decoded result. |
| 30 | pub async fn call_tool(&self, name: &str, arguments: Value) -> Result<Value> { |
| 31 | let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); |
| 32 | let request = json!({ |
| 33 | "jsonrpc": "2.0", |
| 34 | "id": id, |
| 35 | "method": "tools/call", |
| 36 | "params": { "name": name, "arguments": arguments }, |
| 37 | }); |
| 38 | |
| 39 | debug!(tool = name, "calling MCP tool"); |
| 40 | |
| 41 | let builder = self |
| 42 | .request(reqwest::Method::POST, "mcp") |
| 43 | .header(reqwest::header::ACCEPT, "application/json") |
| 44 | .json(&request); |
| 45 | |
| 46 | let response: Value = self.send(builder).await?; |
| 47 | decode_tool_result(name, response) |
| 48 | } |
| 49 | |
| 50 | /// [`Client::call_tool`], deserialized into a concrete type. |
| 51 | pub async fn call_tool_as<T: DeserializeOwned>( |
| 52 | &self, |
| 53 | name: &str, |
| 54 | arguments: Value, |
| 55 | ) -> Result<T> { |
| 56 | let value = self.call_tool(name, arguments).await?; |
| 57 | serde_json::from_value(value) |
| 58 | .map_err(|e| ApiError::Parse(format!("tool {name} returned an unexpected shape: {e}"))) |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | /// Unwrap a JSON-RPC envelope down to the tool's payload. |
| 63 | /// |
| 64 | /// Three failure modes are distinct and all reachable: a protocol-level |
| 65 | /// `error` (unknown tool, malformed params), an in-band tool failure |
| 66 | /// (`isError: true`, e.g. "No pull request #9"), and a success whose text block |
| 67 | /// isn't the JSON we expect. |
| 68 | fn decode_tool_result(name: &str, response: Value) -> Result<Value> { |
| 69 | if let Some(error) = response.get("error") { |
| 70 | let message = error |
| 71 | .get("message") |
| 72 | .and_then(Value::as_str) |
| 73 | .unwrap_or("The server rejected the request."); |
| 74 | return Err(ApiError::Tool(message.to_string())); |
| 75 | } |
| 76 | |
| 77 | let result = response |
| 78 | .get("result") |
| 79 | .ok_or_else(|| ApiError::Parse(format!("tool {name} returned no result")))?; |
| 80 | |
| 81 | let text = result |
| 82 | .get("content") |
| 83 | .and_then(Value::as_array) |
| 84 | .and_then(|blocks| blocks.first()) |
| 85 | .and_then(|block| block.get("text")) |
| 86 | .and_then(Value::as_str) |
| 87 | .ok_or_else(|| ApiError::Parse(format!("tool {name} returned no content")))?; |
| 88 | |
| 89 | if result.get("isError").and_then(Value::as_bool) == Some(true) { |
| 90 | return Err(ApiError::Tool(text.to_string())); |
| 91 | } |
| 92 | |
| 93 | // Tools serialize their payload as pretty-printed JSON inside a text block. |
| 94 | // A tool that legitimately returns prose would land in the fallback. |
| 95 | serde_json::from_str(text).or_else(|_| Ok(Value::String(text.to_string()))) |
| 96 | } |
| 97 | |
| 98 | #[cfg(test)] |
| 99 | mod tests { |
| 100 | use super::*; |
| 101 | |
| 102 | fn envelope(content: Value, is_error: bool) -> Value { |
| 103 | json!({ |
| 104 | "jsonrpc": "2.0", |
| 105 | "id": 1, |
| 106 | "result": { |
| 107 | "content": [{ "type": "text", "text": content }], |
| 108 | "isError": is_error, |
| 109 | } |
| 110 | }) |
| 111 | } |
| 112 | |
| 113 | #[test] |
| 114 | fn a_successful_call_yields_the_parsed_payload() { |
| 115 | let response = envelope(json!(r#"[{"number":3,"title":"Fix it"}]"#), false); |
| 116 | let value = decode_tool_result("list_issues", response).unwrap(); |
| 117 | assert_eq!(value[0]["number"], 3); |
| 118 | } |
| 119 | |
| 120 | #[test] |
| 121 | fn an_in_band_tool_failure_becomes_a_tool_error() { |
| 122 | let response = envelope(json!("No pull request #9 in a/b."), true); |
| 123 | match decode_tool_result("get_pull_request", response) { |
| 124 | Err(ApiError::Tool(message)) => assert!(message.contains("No pull request #9")), |
| 125 | other => panic!("expected a Tool error, got {other:?}"), |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | #[test] |
| 130 | fn a_protocol_error_becomes_a_tool_error() { |
| 131 | let response = json!({ |
| 132 | "jsonrpc": "2.0", "id": 1, |
| 133 | "error": { "code": -32602, "message": "Unknown tool: nope" } |
| 134 | }); |
| 135 | match decode_tool_result("nope", response) { |
| 136 | Err(ApiError::Tool(message)) => assert!(message.contains("Unknown tool")), |
| 137 | other => panic!("expected a Tool error, got {other:?}"), |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | #[test] |
| 142 | fn a_non_json_text_block_is_returned_as_a_string() { |
| 143 | let response = envelope(json!("just prose"), false); |
| 144 | let value = decode_tool_result("web_search", response).unwrap(); |
| 145 | assert_eq!(value, Value::String("just prose".into())); |
| 146 | } |
| 147 | |
| 148 | #[test] |
| 149 | fn a_missing_result_is_a_parse_error() { |
| 150 | let response = json!({ "jsonrpc": "2.0", "id": 1 }); |
| 151 | assert!(matches!( |
| 152 | decode_tool_result("list_issues", response), |
| 153 | Err(ApiError::Parse(_)) |
| 154 | )); |
| 155 | } |
| 156 | } |