use std::time::Duration;
use async_trait::async_trait;
use clawft_types::secret::SecretString;
use serde_json::{Value, json};
use super::fleet_inventory::{FleetError, FleetMcp};
pub const COGNITUM_MCP_URL: &str = "https://api.cognitum.one/v1/mcp";
const TIMEOUT: Duration = Duration::from_secs(30);
const MAX_REPLY_BYTES: usize = 4 * 1024 * 1024;
pub trait OAuthTokens: Send + Sync {
fn access_token(&self) -> Result<SecretString, FleetError>;
}
pub(crate) struct HttpFleetMcp {
url: String,
client: reqwest::Client,
tokens: Box<dyn OAuthTokens>,
}
impl HttpFleetMcp {
pub(crate) fn new(url: &str, tokens: Box<dyn OAuthTokens>) -> Result<Self, FleetError> {
let bad = || {
FleetError::Transport(
"the fleet MCP URL must be https:// (or http:// to localhost, 127.0.0.1 or ::1)"
.into(),
)
};
let parsed = reqwest::Url::parse(url).map_err(|_| bad())?;
let local = matches!(parsed.host_str(), Some("localhost" | "127.0.0.1" | "[::1]"));
let ok = match parsed.scheme() {
"https" => parsed.host_str().is_some_and(|h| !h.is_empty()),
"http" => local && parsed.username().is_empty(),
_ => false,
};
if !ok {
return Err(bad());
}
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(TIMEOUT)
.build()
.map_err(|e| FleetError::Transport(format!("http client: {e}")))?;
Ok(Self {
url: url.to_string(),
client,
tokens,
})
}
async fn rpc(
&self,
session: Option<&str>,
body: Value,
) -> Result<(Option<String>, Value), FleetError> {
let token = self.tokens.access_token()?;
let mut req = self
.client
.post(&self.url)
.header("accept", "application/json, text/event-stream")
.bearer_auth(token.expose())
.json(&body);
if let Some(s) = session {
req = req.header("mcp-session-id", s);
}
let fail = |e: reqwest::Error| FleetError::Transport(e.without_url().to_string());
let resp = req.send().await.map_err(fail)?;
let status = resp.status().as_u16();
if status == 401 || status == 403 {
return Err(FleetError::Unauthorized(status));
}
if !(200..300).contains(&status) {
return Err(FleetError::Transport(format!("HTTP {status}")));
}
let sid = resp
.headers()
.get("mcp-session-id")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let sse = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.is_some_and(|t| t.starts_with("text/event-stream"));
let too_large = || FleetError::Transport("reply too large".into());
if resp
.content_length()
.is_some_and(|n| n > MAX_REPLY_BYTES as u64)
{
return Err(too_large());
}
let mut resp = resp;
let mut bytes = Vec::new();
while let Some(chunk) = resp.chunk().await.map_err(fail)? {
if bytes.len() + chunk.len() > MAX_REPLY_BYTES {
return Err(too_large());
}
bytes.extend_from_slice(&chunk);
}
let text = String::from_utf8_lossy(&bytes);
let payload = if sse {
text.lines()
.filter_map(|l| l.strip_prefix("data:"))
.map(str::trim)
.find(|d| d.starts_with('{'))
.unwrap_or("")
.to_string()
} else {
text.into_owned()
};
let v = if payload.trim().is_empty() {
Value::Null
} else {
serde_json::from_str(&payload)
.map_err(|_| FleetError::Transport("reply is not JSON-RPC".into()))?
};
Ok((sid, v))
}
}
#[async_trait]
impl FleetMcp for HttpFleetMcp {
async fn call_tool(&self, name: &str, args: Value) -> Result<Value, FleetError> {
let (sid, init) = self
.rpc(
None,
json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
"protocolVersion": "2025-03-26", "capabilities": {},
"clientInfo": {"name": "weftos-fleet-inventory", "version": "1"}}}),
)
.await?;
if let Some(e) = init.get("error") {
return Err(FleetError::Transport(format!("initialize: {}", brief(e))));
}
self.rpc(
sid.as_deref(),
json!({"jsonrpc": "2.0", "method": "notifications/initialized"}),
)
.await?;
let (_, reply) = self
.rpc(
sid.as_deref(),
json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": {"name": name, "arguments": args}}),
)
.await?;
if let Some(e) = reply.get("error") {
return Err(FleetError::Transport(format!("{name}: {}", brief(e))));
}
let result = reply
.get("result")
.ok_or_else(|| FleetError::Schema("no result".into()))?;
if result.get("isError").and_then(Value::as_bool) == Some(true) {
return Err(FleetError::Transport(format!("{name} reported an error")));
}
if let Some(s) = result.get("structuredContent") {
return Ok(s.clone());
}
let text = result
.get("content")
.and_then(Value::as_array)
.and_then(|c| c.iter().find_map(|p| p.get("text").and_then(Value::as_str)))
.ok_or_else(|| FleetError::Schema("no content text".into()))?;
serde_json::from_str(text).map_err(|_| FleetError::Schema("content is not JSON".into()))
}
}
fn brief(e: &Value) -> String {
e.get("message")
.and_then(Value::as_str)
.unwrap_or("error")
.chars()
.take(200)
.collect()
}