Skip to main content

oxicode_agent/mcp/
client.rs

1//! MCP JSON-RPC client.
2//!
3//! Communicates with an MCP server through a [`McpTransport`]
4//! (currently only [`StdioTransport`]). Owns the request id counter and
5//! high-level methods (`tools/list`, `tools/call`, ...); delegates raw I/O
6//! and request/response correlation to the transport.
7
8use super::transport::{InboundHandler, McpTransport, stdio::StdioTransport};
9use super::types::{
10    JsonRpcNotification, JsonRpcRequest, McpCallResult, McpContent, McpToolDef, RawJsonRpcMessage,
11    ServerInfo,
12};
13use anyhow::{Context, Result};
14use std::collections::HashMap;
15
16/// MCP protocol version we advertise during initialization.
17const MCP_PROTOCOL_VERSION: &str = "2025-03-26";
18
19/// MCP prompt template.
20#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
21pub struct McpPrompt {
22    /// Prompt identifier.
23    pub name: String,
24    /// Optional human-readable description.
25    #[serde(default)]
26    pub description: Option<String>,
27    /// Arguments accepted by the prompt template.
28    #[serde(default)]
29    pub arguments: Vec<McpPromptArgument>,
30}
31
32/// A single argument accepted by an [`McpPrompt`] template.
33#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
34pub struct McpPromptArgument {
35    /// Argument name.
36    pub name: String,
37    /// Optional human-readable description.
38    #[serde(default)]
39    pub description: Option<String>,
40    /// Whether the argument must be supplied.
41    #[serde(default)]
42    pub required: bool,
43}
44
45/// MCP log level for `logging/setLevel`.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum McpLogLevel {
48    /// Fine-grained diagnostic information.
49    Debug,
50    /// General informational information.
51    Info,
52    /// Normal-but-significant conditions.
53    Notice,
54    /// Indication that something unexpected happened.
55    Warning,
56    /// Runtime errors that do not halt execution.
57    Error,
58    /// Critical conditions requiring immediate attention.
59    Critical,
60    /// Action must be taken immediately.
61    Alert,
62    /// System is unusable.
63    Emergency,
64}
65
66impl McpLogLevel {
67    /// Return the wire string representation used by the MCP protocol.
68    pub fn as_str(&self) -> &'static str {
69        match self {
70            McpLogLevel::Debug => "debug",
71            McpLogLevel::Info => "info",
72            McpLogLevel::Notice => "notice",
73            McpLogLevel::Warning => "warning",
74            McpLogLevel::Error => "error",
75            McpLogLevel::Critical => "critical",
76            McpLogLevel::Alert => "alert",
77            McpLogLevel::Emergency => "emergency",
78        }
79    }
80}
81
82/// MCP sampling request — server asks oxicode to make an LLM call.
83#[derive(Debug, Clone, serde::Serialize)]
84pub struct McpSamplingRequest {
85    /// Conversation messages to sample from.
86    pub messages: Vec<serde_json::Value>,
87    /// Optional system prompt to prepend.
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub system_prompt: Option<String>,
90    /// Maximum number of tokens to generate.
91    pub max_tokens: u32,
92    /// Optional sampling temperature.
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub temperature: Option<f32>,
95}
96
97/// An MCP client connected to a single server through a transport.
98pub struct McpClient {
99    /// Underlying transport (stdio, HTTP/SSE, ...).
100    transport: Box<dyn McpTransport>,
101    /// Next JSON-RPC request ID.
102    next_id: u64,
103    /// Server info from the initialize handshake.
104    pub server_info: ServerInfo,
105}
106
107impl std::fmt::Debug for McpClient {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        f.debug_struct("McpClient")
110            .field("server_info", &self.server_info)
111            .field("next_id", &self.next_id)
112            .field("connected", &self.transport.is_connected())
113            .finish()
114    }
115}
116
117impl McpClient {
118    /// Connect to an MCP server via stdio transport.
119    ///
120    /// Performs the full initialization handshake:
121    /// 1. Spawn the process
122    /// 2. Install the default inbound responder
123    /// 3. Send `initialize` request
124    /// 4. Send `notifications/initialized`
125    pub async fn connect(
126        command: &str,
127        args: &[String],
128        env: &HashMap<String, String>,
129        cwd: Option<&str>,
130        debug: bool,
131    ) -> Result<Self> {
132        let transport: Box<dyn McpTransport> =
133            Box::new(StdioTransport::spawn(command, args, env, cwd, debug, None)?);
134        Self::connect_with_transport(transport).await
135    }
136
137    /// Connect through a pre-built transport (used by tests and by future
138    /// HTTP/SSE transports). Installs the default inbound responder
139    /// (`ping` → empty result, `roots/list` → empty roots, others → -32601)
140    /// before the initialize handshake so the client does not deadlock on
141    /// servers that send requests between responses.
142    pub async fn connect_with_transport(mut transport: Box<dyn McpTransport>) -> Result<Self> {
143        transport.set_inbound_handler(default_inbound_handler());
144        let mut client = Self {
145            transport,
146            next_id: 1,
147            server_info: ServerInfo {
148                name: String::new(),
149                version: None,
150                protocol_version: String::new(),
151            },
152        };
153        client.initialize().await?;
154        Ok(client)
155    }
156
157    /// Perform the MCP initialize handshake.
158    async fn initialize(&mut self) -> Result<()> {
159        let params = serde_json::json!({
160            "protocolVersion": MCP_PROTOCOL_VERSION,
161            "capabilities": {},
162            "clientInfo": {
163                "name": "oxicode-mcp",
164                "version": env!("CARGO_PKG_VERSION")
165            }
166        });
167
168        let result = self
169            .send_request("initialize", Some(params))
170            .await
171            .context("MCP initialize failed")?;
172
173        if let Some(info) = result.get("serverInfo") {
174            self.server_info.name = info
175                .get("name")
176                .and_then(|v| v.as_str())
177                .unwrap_or("unknown")
178                .to_string();
179            self.server_info.version = info
180                .get("version")
181                .and_then(|v| v.as_str())
182                .map(String::from);
183        }
184        if let Some(version) = result.get("protocolVersion").and_then(|v| v.as_str()) {
185            self.server_info.protocol_version = version.to_string();
186        }
187
188        let notification = JsonRpcNotification {
189            jsonrpc: "2.0",
190            method: "notifications/initialized".to_string(),
191            params: None,
192        };
193        let json = serde_json::to_string(&notification)?;
194        self.transport
195            .notify(&json)
196            .await
197            .context("Failed to send notifications/initialized")?;
198
199        Ok(())
200    }
201
202    /// List all tools provided by the server.
203    pub async fn list_tools(&mut self) -> Result<Vec<McpToolDef>> {
204        let result = self
205            .send_request("tools/list", None)
206            .await
207            .context("MCP tools/list failed")?;
208
209        let tools = result
210            .get("tools")
211            .cloned()
212            .and_then(|v| serde_json::from_value::<Vec<McpToolDef>>(v).ok())
213            .unwrap_or_else(|| {
214                tracing::warn!(
215                    "MCP: failed to parse tools/list response from '{}'",
216                    self.server_info.name
217                );
218                Vec::new()
219            });
220
221        Ok(tools)
222    }
223
224    /// Call a tool on the server.
225    pub async fn call_tool(
226        &mut self,
227        name: &str,
228        args: serde_json::Value,
229    ) -> Result<McpCallResult> {
230        let params = serde_json::json!({
231            "name": name,
232            "arguments": args
233        });
234
235        let result = self
236            .send_request("tools/call", Some(params))
237            .await
238            .with_context(|| format!("MCP tools/call '{}' failed", name))?;
239
240        let is_error = result
241            .get("isError")
242            .and_then(|v| v.as_bool())
243            .unwrap_or(false);
244
245        let content = result
246            .get("content")
247            .cloned()
248            .and_then(|v| serde_json::from_value::<Vec<McpContent>>(v).ok())
249            .unwrap_or_default();
250
251        Ok(McpCallResult { content, is_error })
252    }
253
254    /// List resources provided by the server.
255    pub async fn list_resources(&mut self) -> Result<Vec<serde_json::Value>> {
256        let result = self
257            .send_request("resources/list", None)
258            .await
259            .context("MCP resources/list failed")?;
260
261        Ok(result
262            .get("resources")
263            .and_then(|v| v.as_array())
264            .cloned()
265            .unwrap_or_default())
266    }
267
268    /// Read a resource from the server.
269    pub async fn read_resource(&mut self, uri: &str) -> Result<Vec<McpContent>> {
270        let params = serde_json::json!({ "uri": uri });
271        let result = self
272            .send_request("resources/read", Some(params))
273            .await
274            .with_context(|| format!("MCP resources/read '{}' failed", uri))?;
275
276        let contents = result
277            .get("contents")
278            .and_then(|v| v.as_array())
279            .cloned()
280            .unwrap_or_default();
281
282        let mut content = Vec::new();
283        for item in contents {
284            if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
285                content.push(McpContent::Text {
286                    text: text.to_string(),
287                });
288            } else if item.get("blob").is_some() {
289                content.push(McpContent::Text {
290                    text: format!(
291                        "[Binary data: {}]",
292                        item.get("mimeType")
293                            .and_then(|m| m.as_str())
294                            .unwrap_or("unknown")
295                    ),
296                });
297            }
298        }
299        Ok(content)
300    }
301
302    /// List prompt templates available on the MCP server.
303    pub async fn list_prompts(&mut self) -> Result<Vec<McpPrompt>> {
304        let result = self.send_request("prompts/list", None).await?;
305        let prompts = result
306            .get("prompts")
307            .cloned()
308            .unwrap_or(serde_json::Value::Array(vec![]));
309        serde_json::from_value(prompts)
310            .map_err(|e| anyhow::anyhow!("Failed to parse prompts/list response: {}", e))
311    }
312
313    /// Get a prompt template with arguments applied.
314    pub async fn get_prompt(
315        &mut self,
316        name: &str,
317        args: HashMap<String, String>,
318    ) -> Result<Vec<serde_json::Value>> {
319        let params = serde_json::json!({
320            "name": name,
321            "arguments": args
322        });
323        let result = self.send_request("prompts/get", Some(params)).await?;
324        let messages = result
325            .get("messages")
326            .cloned()
327            .unwrap_or(serde_json::Value::Array(vec![]));
328        Ok(serde_json::from_value(messages).unwrap_or_default())
329    }
330
331    /// Set the minimum log level for MCP server notifications.
332    pub async fn set_log_level(&mut self, level: McpLogLevel) -> Result<()> {
333        let params = serde_json::json!({ "level": level.as_str() });
334        self.send_request("logging/setLevel", Some(params)).await?;
335        Ok(())
336    }
337
338    /// Request the host to create a message via LLM sampling.
339    pub async fn create_sample(
340        &mut self,
341        request: McpSamplingRequest,
342    ) -> Result<serde_json::Value> {
343        let params = serde_json::to_value(&request)
344            .map_err(|e| anyhow::anyhow!("Failed to serialize sampling request: {}", e))?;
345        self.send_request("sampling/createMessage", Some(params))
346            .await
347    }
348
349    /// Send a low-level ping to verify the server is alive.
350    pub async fn ping(&mut self) -> Result<()> {
351        self.send_request("ping", None).await?;
352        Ok(())
353    }
354
355    /// Whether the transport is currently connected.
356    pub fn is_connected(&self) -> bool {
357        self.transport.is_connected()
358    }
359
360    /// Replace the inbound handler installed by `connect_with_transport`.
361    /// Used by [`crate::mcp::McpManager`] to install its manager-level
362    /// handler (notifications/tools/list_changed refresh, ...) after
363    /// the initialize handshake.
364    pub fn set_inbound_handler(&mut self, handler: InboundHandler) {
365        self.transport.set_inbound_handler(handler);
366    }
367
368    /// Shut down the client gracefully.
369    pub async fn close(&mut self) -> Result<()> {
370        self.transport.close().await
371    }
372
373    // ── JSON-RPC request/response correlation ─────────────────────
374
375    /// Send a JSON-RPC request and wait for the matching response.
376    /// Correlation is handled by the transport.
377    async fn send_request(
378        &mut self,
379        method: &str,
380        params: Option<serde_json::Value>,
381    ) -> Result<serde_json::Value> {
382        let id = self.next_id;
383        self.next_id += 1;
384
385        let request = JsonRpcRequest {
386            jsonrpc: "2.0",
387            id,
388            method: method.to_string(),
389            params,
390        };
391
392        let json = serde_json::to_string(&request)?;
393        let resp = self
394            .transport
395            .request(id, &json)
396            .await
397            .with_context(|| format!("MCP request '{}' failed", method))?;
398
399        if let Some(error) = resp.error {
400            return Err(anyhow::anyhow!(
401                "JSON-RPC error {}: {}",
402                error.code,
403                error.message
404            ));
405        }
406        Ok(resp.result.unwrap_or(serde_json::Value::Null))
407    }
408}
409
410/// Default inbound responder for server→client requests and notifications.
411///
412/// Used by [`McpClient::connect_with_transport`] so the client can talk to
413/// MCP servers that send `ping` or `roots/list` (or any server→client
414/// request) during the handshake without deadlocking the request loop.
415///
416/// - `ping` → `{"result": {}}`
417/// - `roots/list` → `{"result": {"roots": []}}`
418/// - other server→client requests → `{"error": {"code": -32601, ...}}`
419/// - notifications (no id) → no response (handler return is ignored)
420fn default_inbound_handler() -> InboundHandler {
421    Box::new(|msg: RawJsonRpcMessage| -> Option<serde_json::Value> {
422        // Only build a response for server→client requests (id present).
423        let id = msg.id?;
424        let method = msg.method.as_deref()?;
425        Some(match method {
426            "ping" => serde_json::json!({"jsonrpc": "2.0", "id": id, "result": {}}),
427            "roots/list" => serde_json::json!({
428                "jsonrpc": "2.0",
429                "id": id,
430                "result": {"roots": []}
431            }),
432            _ => serde_json::json!({
433                "jsonrpc": "2.0",
434                "id": id,
435                "error": {"code": -32601, "message": "Method not found"}
436            }),
437        })
438    })
439}