clawft_kernel/workload_runtime/
fleet_mcp.rs1use std::time::Duration;
11
12use async_trait::async_trait;
13use clawft_types::secret::SecretString;
14use serde_json::{Value, json};
15
16use super::fleet_inventory::{FleetError, FleetMcp};
17
18pub const COGNITUM_MCP_URL: &str = "https://api.cognitum.one/v1/mcp";
20const TIMEOUT: Duration = Duration::from_secs(30);
21const MAX_REPLY_BYTES: usize = 4 * 1024 * 1024;
22
23pub trait OAuthTokens: Send + Sync {
25 fn access_token(&self) -> Result<SecretString, FleetError>;
27}
28
29pub(crate) struct HttpFleetMcp {
34 url: String,
35 client: reqwest::Client,
36 tokens: Box<dyn OAuthTokens>,
37}
38
39impl HttpFleetMcp {
40 pub(crate) fn new(url: &str, tokens: Box<dyn OAuthTokens>) -> Result<Self, FleetError> {
42 let bad = || {
43 FleetError::Transport(
44 "the fleet MCP URL must be https:// (or http:// to localhost, 127.0.0.1 or ::1)"
45 .into(),
46 )
47 };
48 let parsed = reqwest::Url::parse(url).map_err(|_| bad())?;
49 let local = matches!(parsed.host_str(), Some("localhost" | "127.0.0.1" | "[::1]"));
52 let ok = match parsed.scheme() {
53 "https" => parsed.host_str().is_some_and(|h| !h.is_empty()),
54 "http" => local && parsed.username().is_empty(),
55 _ => false,
56 };
57 if !ok {
58 return Err(bad());
59 }
60 let client = reqwest::Client::builder()
61 .redirect(reqwest::redirect::Policy::none())
62 .timeout(TIMEOUT)
63 .build()
64 .map_err(|e| FleetError::Transport(format!("http client: {e}")))?;
65 Ok(Self {
66 url: url.to_string(),
67 client,
68 tokens,
69 })
70 }
71
72 async fn rpc(
73 &self,
74 session: Option<&str>,
75 body: Value,
76 ) -> Result<(Option<String>, Value), FleetError> {
77 let token = self.tokens.access_token()?;
78 let mut req = self
79 .client
80 .post(&self.url)
81 .header("accept", "application/json, text/event-stream")
82 .bearer_auth(token.expose())
83 .json(&body);
84 if let Some(s) = session {
85 req = req.header("mcp-session-id", s);
86 }
87 let fail = |e: reqwest::Error| FleetError::Transport(e.without_url().to_string());
88 let resp = req.send().await.map_err(fail)?;
89 let status = resp.status().as_u16();
90 if status == 401 || status == 403 {
91 return Err(FleetError::Unauthorized(status));
92 }
93 if !(200..300).contains(&status) {
94 return Err(FleetError::Transport(format!("HTTP {status}")));
95 }
96 let sid = resp
97 .headers()
98 .get("mcp-session-id")
99 .and_then(|v| v.to_str().ok())
100 .map(str::to_string);
101 let sse = resp
102 .headers()
103 .get("content-type")
104 .and_then(|v| v.to_str().ok())
105 .is_some_and(|t| t.starts_with("text/event-stream"));
106 let too_large = || FleetError::Transport("reply too large".into());
107 if resp
108 .content_length()
109 .is_some_and(|n| n > MAX_REPLY_BYTES as u64)
110 {
111 return Err(too_large());
112 }
113 let mut resp = resp;
116 let mut bytes = Vec::new();
117 while let Some(chunk) = resp.chunk().await.map_err(fail)? {
118 if bytes.len() + chunk.len() > MAX_REPLY_BYTES {
119 return Err(too_large());
120 }
121 bytes.extend_from_slice(&chunk);
122 }
123 let text = String::from_utf8_lossy(&bytes);
124 let payload = if sse {
125 text.lines()
126 .filter_map(|l| l.strip_prefix("data:"))
127 .map(str::trim)
128 .find(|d| d.starts_with('{'))
129 .unwrap_or("")
130 .to_string()
131 } else {
132 text.into_owned()
133 };
134 let v = if payload.trim().is_empty() {
135 Value::Null
136 } else {
137 serde_json::from_str(&payload)
138 .map_err(|_| FleetError::Transport("reply is not JSON-RPC".into()))?
139 };
140 Ok((sid, v))
141 }
142}
143
144#[async_trait]
145impl FleetMcp for HttpFleetMcp {
146 async fn call_tool(&self, name: &str, args: Value) -> Result<Value, FleetError> {
147 let (sid, init) = self
148 .rpc(
149 None,
150 json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
151 "protocolVersion": "2025-03-26", "capabilities": {},
152 "clientInfo": {"name": "weftos-fleet-inventory", "version": "1"}}}),
153 )
154 .await?;
155 if let Some(e) = init.get("error") {
156 return Err(FleetError::Transport(format!("initialize: {}", brief(e))));
157 }
158 self.rpc(
159 sid.as_deref(),
160 json!({"jsonrpc": "2.0", "method": "notifications/initialized"}),
161 )
162 .await?;
163 let (_, reply) = self
164 .rpc(
165 sid.as_deref(),
166 json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call",
167 "params": {"name": name, "arguments": args}}),
168 )
169 .await?;
170 if let Some(e) = reply.get("error") {
171 return Err(FleetError::Transport(format!("{name}: {}", brief(e))));
172 }
173 let result = reply
174 .get("result")
175 .ok_or_else(|| FleetError::Schema("no result".into()))?;
176 if result.get("isError").and_then(Value::as_bool) == Some(true) {
177 return Err(FleetError::Transport(format!("{name} reported an error")));
178 }
179 if let Some(s) = result.get("structuredContent") {
180 return Ok(s.clone());
181 }
182 let text = result
183 .get("content")
184 .and_then(Value::as_array)
185 .and_then(|c| c.iter().find_map(|p| p.get("text").and_then(Value::as_str)))
186 .ok_or_else(|| FleetError::Schema("no content text".into()))?;
187 serde_json::from_str(text).map_err(|_| FleetError::Schema("content is not JSON".into()))
188 }
189}
190
191fn brief(e: &Value) -> String {
192 e.get("message")
193 .and_then(Value::as_str)
194 .unwrap_or("error")
195 .chars()
196 .take(200)
197 .collect()
198}