//! A small client for the project's MCP backend (Streamable HTTP).
//!
//! The backend is stateless, so each tool call is one JSON-RPC `tools/call`
//! POST. The signed-in user's bearer token is passed per call.
use std::fmt;
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
#[derive(Debug, Clone)]
pub struct McpClient {
http: reqwest::Client,
url: String,
}
/// A failed tool call.
#[derive(Debug, Clone)]
pub struct ToolError {
/// The backend's error code (`unauthenticated`, `forbidden`, `not_found`,
/// ...), or `unavailable` when the backend could not be reached.
pub code: String,
pub message: String,
}
impl fmt::Display for ToolError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl ToolError {
fn new(code: &str, message: impl Into<String>) -> Self {
Self {
code: code.to_owned(),
message: message.into(),
}
}
}
impl McpClient {
pub fn new(url: String) -> Self {
Self {
http: reqwest::Client::new(),
url,
}
}
pub fn url(&self) -> &str {
&self.url
}
/// Calls `tool` with `arguments`, as the user owning `token` if given, and
/// decodes its structured result.
pub async fn call<T: DeserializeOwned>(
&self,
tool: &str,
arguments: Value,
token: Option<&str>,
) -> Result<T, ToolError> {
let message = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": tool, "arguments": arguments},
});
let mut request = self
.http
.post(&self.url)
.header("accept", "application/json, text/event-stream")
.json(&message);
if let Some(token) = token {
request = request.bearer_auth(token);
}
let unavailable = |err: reqwest::Error| {
tracing::warn!("MCP backend at {}: {err}", self.url);
ToolError::new("unavailable", "the backend is not reachable right now")
};
let response: Value = request
.send()
.await
.and_then(reqwest::Response::error_for_status)
.map_err(unavailable)?
.json()
.await
.map_err(unavailable)?;
if let Some(error) = response.get("error") {
let message = error["message"].as_str().unwrap_or("request failed");
return Err(ToolError::new("protocol_error", message));
}
let result = &response["result"];
let content = result["structuredContent"].clone();
if result["isError"] == true {
let code = content["code"].as_str().unwrap_or("tool_error");
let message = content["error"]
.as_str()
.or_else(|| result["content"][0]["text"].as_str())
.unwrap_or("the request failed");
return Err(ToolError::new(code, message));
}
serde_json::from_value(content)
.map_err(|err| ToolError::new("protocol_error", format!("unexpected result: {err}")))
}
}