Files
Skald-Circle/crates/mcp-client/src/http_server.rs
T
2026-07-10 15:02:09 +01:00

424 lines
17 KiB
Rust

use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use anyhow::Result;
use async_trait::async_trait;
use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue};
use serde_json::{Value, json};
use tracing::{debug, warn};
use crate::{McpCallResult, McpServerClient, McpTool, extract_text, interpolate_env};
use crate::config::McpServerConfig;
const CALL_TIMEOUT_SECS: u64 = 120;
/// Best-effort cancellation for an in-flight HTTP `tools/call`: if dropped while
/// armed (a `/stop` drops the request future, or the request timed out), it POSTs
/// `notifications/cancelled` so the server can stop. Correlation is weaker than on
/// stdio — the server must map `requestId` to the abandoned POST, which not every
/// server does — hence best-effort. Disarmed once the server responds (or when a
/// non-timeout send error proves the server never received the request).
struct HttpCancelOnDrop {
id: u64,
client: reqwest::Client,
url: String,
headers: HeaderMap,
name: String,
reason: &'static str,
armed: bool,
}
impl HttpCancelOnDrop {
fn disarm(&mut self) {
self.armed = false;
}
}
impl Drop for HttpCancelOnDrop {
fn drop(&mut self) {
if !self.armed {
return;
}
let (id, client, url, headers, name, reason) =
(self.id, self.client.clone(), self.url.clone(), self.headers.clone(), self.name.clone(), self.reason);
tokio::spawn(async move {
debug!("MCP http '{name}': notifications/cancelled for request {id} ({reason})");
let _ = client.post(&url).headers(headers)
.json(&crate::cancelled_notification(id, reason))
.send().await;
});
}
}
/// Cooperative `tasks/cancel` for a block-and-poll `poll_task` over HTTP: if the
/// poll future is dropped while still polling (a `/stop`) or hits its deadline,
/// POST `tasks/cancel` best-effort. Disarmed once the task reaches a terminal state.
struct HttpTaskCancelOnDrop {
request_id: u64,
task_id: String,
client: reqwest::Client,
url: String,
headers: HeaderMap,
name: String,
armed: bool,
}
impl HttpTaskCancelOnDrop {
fn disarm(&mut self) {
self.armed = false;
}
}
impl Drop for HttpTaskCancelOnDrop {
fn drop(&mut self) {
if !self.armed {
return;
}
let (request_id, task_id, client, url, headers, name) =
(self.request_id, self.task_id.clone(), self.client.clone(), self.url.clone(), self.headers.clone(), self.name.clone());
tokio::spawn(async move {
debug!("MCP http '{name}': tasks/cancel for task {task_id}");
let _ = client.post(&url).headers(headers)
.json(&crate::tasks_cancel_request(request_id, &task_id))
.send().await;
});
}
}
pub struct McpHttpServer {
name: String,
url: String,
client: reqwest::Client,
headers: HeaderMap,
/// Set after the `initialize` response — required by stateful servers like Tavily.
session_id: Mutex<Option<String>>,
/// Protocol version negotiated in the `initialize` response (falls back to
/// [`crate::PROTOCOL_VERSION`]). Once set, echoed in the `MCP-Protocol-Version`
/// header on every post-initialize request, per the Streamable HTTP spec.
protocol_version: Mutex<Option<String>>,
next_id: AtomicU64,
tools: Vec<McpTool>,
/// Capabilities the server advertised in its `InitializeResult`. Captured so a
/// future Tasks polling loop can gate on `tasks` support; unused for now.
server_capabilities: Value,
}
impl McpHttpServer {
pub async fn start(cfg: &McpServerConfig) -> Result<Self> {
let url = cfg.url.as_deref()
.ok_or_else(|| anyhow::anyhow!("http server '{}' requires 'url'", cfg.name))?
.trim_end_matches('/')
.to_string();
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
headers.insert(ACCEPT, HeaderValue::from_static("application/json, text/event-stream"));
if let Some(key) = &cfg.api_key {
let val = interpolate_env(key);
let bearer = format!("Bearer {val}");
headers.insert(AUTHORIZATION, bearer.parse()
.map_err(|_| anyhow::anyhow!("invalid api_key for '{}'", cfg.name))?);
}
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(CALL_TIMEOUT_SECS))
.build()?;
let server = McpHttpServer {
name: cfg.name.clone(),
url,
client,
headers,
session_id: Mutex::new(None),
protocol_version: Mutex::new(None),
next_id: AtomicU64::new(1),
tools: Vec::new(),
server_capabilities: json!({}),
};
let init = server.request("initialize", json!({
// The HTTP transport doesn't service the ElicitationHandler (stdio-only),
// so it must NOT advertise the elicitation capability.
"protocolVersion": crate::PROTOCOL_VERSION,
// Experimental Tasks marker only (recognise-but-don't-poll); see the
// stdio transport for the rationale behind keeping it under `experimental`.
"capabilities": { "experimental": { "tasks": {} } },
"clientInfo": { "name": "skald", "version": env!("CARGO_PKG_VERSION") },
})).await?;
// Capture the negotiated version (fall back to our own) so post-initialize
// requests can echo it in the MCP-Protocol-Version header; tolerate a
// downgrade with a warning rather than disconnecting.
let negotiated = init["protocolVersion"].as_str().unwrap_or(crate::PROTOCOL_VERSION);
if negotiated != crate::PROTOCOL_VERSION {
warn!("MCP http '{}': server negotiated protocol {negotiated} (we requested {}); proceeding",
server.name, crate::PROTOCOL_VERSION);
}
*server.protocol_version.lock().unwrap() = Some(negotiated.to_string());
// Capture the server's advertised capabilities for a future Tasks poller.
let server_capabilities = init.get("capabilities").cloned().unwrap_or_else(|| json!({}));
if let Err(e) = server.notify("notifications/initialized", json!({})).await {
warn!("MCP http '{}': initialized notification failed (ignoring): {e}", server.name);
}
// Follow `nextCursor` across pages so large tool lists aren't silently
// truncated; capped at `MAX_TOOL_PAGES` against a stuck cursor.
let mut tools: Vec<McpTool> = Vec::new();
let mut cursor: Option<String> = None;
for page_n in 0..crate::MAX_TOOL_PAGES {
let params = match &cursor {
Some(c) => json!({ "cursor": c }),
None => json!({}),
};
let page = server.request("tools/list", params).await?;
if let Some(arr) = page["tools"].as_array() {
tools.extend(arr.iter().map(|t| McpTool::from_json(&cfg.name, t)));
}
cursor = page["nextCursor"].as_str().filter(|s| !s.is_empty()).map(str::to_string);
if cursor.is_none() {
break;
}
if page_n + 1 == crate::MAX_TOOL_PAGES {
warn!("MCP http '{}': tools/list hit {}-page cap; some tools may be omitted",
server.name, crate::MAX_TOOL_PAGES);
}
}
Ok(McpHttpServer { tools, server_capabilities, ..server })
}
pub fn tools(&self) -> &[McpTool] {
&self.tools
}
/// Capabilities the server advertised at `initialize`. Exposed for a future
/// Tasks polling loop to gate on `tasks` support.
pub fn server_capabilities(&self) -> &Value {
&self.server_capabilities
}
pub async fn call_tool(&self, name: &str, args: Value) -> Result<McpCallResult> {
let mut params = json!({ "name": name, "arguments": args });
if self.wants_task(name) {
// Opt into deferred execution for a task-capable tool (experimental Tasks).
params["task"] = json!({});
}
let result = self.request("tools/call", params).await?;
if result["isError"].as_bool().unwrap_or(false) {
anyhow::bail!("MCP tool error: {}", extract_text(&result));
}
match crate::extract_call_result(&result) {
McpCallResult::Task(task) => self.poll_task(task).await,
other => Ok(other),
}
}
/// True when tool `name` advertises `execution.taskSupport` as `required`/
/// `optional`, so we opt into deferred (Task) execution.
fn wants_task(&self, name: &str) -> bool {
self.tools.iter()
.find(|t| t.name == name)
.and_then(|t| t.task_support.as_deref())
.is_some_and(|s| s == "required" || s == "optional")
}
/// Drives a deferred Task to completion (experimental Tasks, block-and-poll):
/// polls `tasks/get` until a terminal status, then fetches the real result via
/// `tasks/result`. A [`HttpTaskCancelOnDrop`] guard POSTs `tasks/cancel` if this
/// future is dropped (a `/stop`) or the deadline is hit. The overall wait is
/// bounded only by the task's `ttl`, so long tasks no longer hit the 120s wall.
async fn poll_task(&self, task: crate::CreateTaskResult) -> Result<McpCallResult> {
let task_id = task.task_id.as_str();
let cancel_id = self.next_id.fetch_add(1, Ordering::SeqCst);
let mut guard = HttpTaskCancelOnDrop {
request_id: cancel_id,
task_id: task.task_id.clone(),
client: self.client.clone(),
url: self.url.clone(),
headers: self.request_headers(),
name: self.name.clone(),
armed: true,
};
let deadline = crate::poll_deadline(task.ttl_ms);
let mut interval = task.poll_interval_ms;
loop {
tokio::time::sleep(crate::clamp_poll_interval(interval)).await;
if std::time::Instant::now() >= deadline {
anyhow::bail!("MCP http '{}' task '{task_id}' exceeded max wait", self.name);
}
let get = self.request("tasks/get", json!({ "taskId": task_id })).await?;
let Some(state) = crate::CreateTaskResult::parse(&get) else {
anyhow::bail!("MCP http '{}' task '{task_id}': malformed tasks/get response", self.name);
};
interval = state.poll_interval_ms.or(interval);
match state.status {
crate::TaskStatus::Working => continue,
crate::TaskStatus::Completed => break,
crate::TaskStatus::Failed => {
guard.disarm();
anyhow::bail!("MCP http '{}' task '{task_id}' failed: {}", self.name, extract_text(&get));
}
crate::TaskStatus::Cancelled => {
guard.disarm();
anyhow::bail!("MCP http '{}' task '{task_id}' was cancelled by the server", self.name);
}
crate::TaskStatus::InputRequired =>
anyhow::bail!("MCP http '{}' task '{task_id}' requires input mid-task, which isn't supported yet", self.name),
}
}
// Task is terminal (completed) — nothing left to cancel.
guard.disarm();
let result = self.request("tasks/result", json!({ "taskId": task_id })).await?;
if result["isError"].as_bool().unwrap_or(false) {
anyhow::bail!("MCP tool error: {}", extract_text(&result));
}
Ok(crate::extract_call_result(&result))
}
/// Builds per-request headers: the static base plus the captured
/// `Mcp-Session-Id` and `MCP-Protocol-Version`. Both are set only after the
/// `initialize` response, so they're naturally absent on the initialize call
/// itself (the spec scopes the version header to post-initialize requests).
fn request_headers(&self) -> HeaderMap {
let mut headers = self.headers.clone();
if let Some(sid) = self.session_id.lock().unwrap().as_deref() {
if let Ok(val) = HeaderValue::from_str(sid) {
headers.insert("mcp-session-id", val);
}
}
if let Some(ver) = self.protocol_version.lock().unwrap().as_deref() {
if let Ok(val) = HeaderValue::from_str(ver) {
headers.insert("mcp-protocol-version", val);
}
}
headers
}
async fn request(&self, method: &str, params: Value) -> Result<Value> {
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
let body = json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params,
});
let req_headers = self.request_headers();
// Arm a best-effort cancellation guard for cancellable operations only
// (`tools/call`): a `/stop` that drops this future, or a request timeout,
// then POSTs `notifications/cancelled`. Disarmed once the server responds.
let mut cancel_guard = (method == "tools/call").then(|| HttpCancelOnDrop {
id,
client: self.client.clone(),
url: self.url.clone(),
headers: req_headers.clone(),
name: self.name.clone(),
reason: "cancelled by client",
armed: true,
});
let resp = match self.client
.post(&self.url)
.headers(req_headers)
.json(&body)
.send()
.await
{
Ok(r) => r,
Err(e) => {
// A timeout may leave the server still working → cancel it. Any
// other send failure means the server never got the request, so
// there is nothing to cancel.
if let Some(g) = cancel_guard.as_mut() {
if e.is_timeout() { g.reason = "timeout"; } else { g.disarm(); }
}
anyhow::bail!("MCP http '{}' request failed: {e}", self.name);
}
};
// The server responded — the request completed on its side; disarm.
if let Some(g) = cancel_guard.as_mut() { g.disarm(); }
if let Some(sid) = resp.headers().get("mcp-session-id") {
if let Ok(sid_str) = sid.to_str() {
debug!("MCP http '{}': captured session id", self.name);
*self.session_id.lock().unwrap() = Some(sid_str.to_string());
}
}
let status = resp.status();
let content_type = resp.headers()
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let msg: Value = if content_type.contains("text/event-stream") {
parse_sse_response(resp).await
.map_err(|e| anyhow::anyhow!("MCP http '{}' SSE parse error: {e}", self.name))?
} else {
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("MCP http '{}' HTTP {status}: {body}", self.name);
}
resp.json::<Value>().await
.map_err(|e| anyhow::anyhow!("MCP http '{}' JSON decode error: {e}", self.name))?
};
if let Some(error) = msg.get("error") {
anyhow::bail!("MCP http '{}' protocol error: {error}", self.name);
}
Ok(msg["result"].clone())
}
async fn notify(&self, method: &str, params: Value) -> Result<()> {
let body = json!({
"jsonrpc": "2.0",
"method": method,
"params": params,
});
let req_headers = self.request_headers();
self.client
.post(&self.url)
.headers(req_headers)
.json(&body)
.send()
.await
.map_err(|e| anyhow::anyhow!("MCP http notify failed: {e}"))?;
Ok(())
}
}
#[async_trait]
impl McpServerClient for McpHttpServer {
fn tools(&self) -> &[McpTool] { self.tools() }
async fn call_tool(&self, name: &str, args: Value) -> Result<McpCallResult> { self.call_tool(name, args).await }
}
async fn parse_sse_response(resp: reqwest::Response) -> Result<Value> {
let text = resp.text().await?;
for line in text.lines() {
let data = match line.strip_prefix("data:") {
Some(d) => d.trim(),
None => continue,
};
if data == "[DONE]" { break; }
if let Ok(msg) = serde_json::from_str::<Value>(data) {
if msg.get("result").is_some() || msg.get("error").is_some() {
return Ok(msg);
}
}
}
anyhow::bail!("no JSON-RPC result found in SSE response")
}