main
rs 156 lines 5.45 KB
Raw
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 }