Skip to main content

leviath_mcp/
client.rs

1//! MCP client: the protocol layer, over any transport.
2//!
3//! Framing and connection lifecycle live in [`crate::transport`]; this module
4//! owns the MCP conversation itself - the handshake, tool discovery, tool
5//! calls, and the wire types they exchange.
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::HashMap;
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::time::Duration;
12
13use crate::discovery::{MCPServerConfig, ResolvedTransport, ToolMetadata};
14use crate::transport::http::HttpTransport;
15use crate::transport::stdio::StdioTransport;
16use crate::transport::{
17    DEFAULT_CONNECT_TIMEOUT, DEFAULT_REQUEST_TIMEOUT, JsonRpcRequest, Transport,
18};
19
20/// Upper bound on `tools/list` pages followed, so a server that always returns
21/// a `nextCursor` can't spin the discovery loop forever.
22const MAX_TOOL_PAGES: usize = 100;
23
24/// Server capabilities returned after initialization.
25#[derive(Debug, Clone, Serialize, Deserialize, Default)]
26pub struct ServerCapabilities {
27    /// Tool-related capabilities
28    pub tools: Option<ToolsCapability>,
29}
30
31/// Tool capabilities advertised by the server.
32#[derive(Debug, Clone, Serialize, Deserialize, Default)]
33pub struct ToolsCapability {
34    /// Whether the server supports list_changed notifications
35    #[serde(rename = "listChanged")]
36    pub list_changed: Option<bool>,
37}
38
39/// Result returned from a tool call.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct ToolResult {
42    /// Content items in the result.
43    ///
44    /// Defaulted: a server returning only `structuredContent` is still parsed
45    /// rather than rejected.
46    #[serde(default)]
47    pub content: Vec<ToolResultContent>,
48    /// Structured output conforming to the tool's `outputSchema`, when the
49    /// server provides one (MCP 2025-06-18 and later).
50    #[serde(
51        rename = "structuredContent",
52        default,
53        skip_serializing_if = "Option::is_none"
54    )]
55    pub structured_content: Option<Value>,
56    /// Whether the result represents a *tool execution* error (as opposed to a
57    /// JSON-RPC protocol error, which never reaches this type).
58    ///
59    /// The wire name is `isError`. Without the rename this never matched, so
60    /// every failing tool call was silently reported to the model as a success.
61    #[serde(rename = "isError", default)]
62    pub is_error: bool,
63}
64
65/// The resource payload carried by an embedded `resource` content block.
66///
67/// The spec nests this under a `resource` key rather than inlining `uri`/`text`
68/// on the content block itself.
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
70pub struct EmbeddedResource {
71    /// URI identifying the resource.
72    pub uri: String,
73    /// Text contents, for text resources.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub text: Option<String>,
76    /// Base64 contents, for binary resources.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub blob: Option<String>,
79    /// MIME type of the resource.
80    #[serde(rename = "mimeType", default, skip_serializing_if = "Option::is_none")]
81    pub mime_type: Option<String>,
82}
83
84/// Content item in a tool result.
85///
86/// Every wire field name here is the spec's camelCase spelling (`mimeType`),
87/// not a Rust-style snake_case one - mismatches made whole tool results fail to
88/// deserialize.
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
90#[serde(tag = "type")]
91pub enum ToolResultContent {
92    /// Text content
93    #[serde(rename = "text")]
94    Text { text: String },
95    /// Image content (base64 encoded)
96    #[serde(rename = "image")]
97    Image {
98        data: String,
99        #[serde(rename = "mimeType")]
100        mime_type: String,
101    },
102    /// Audio content (base64 encoded)
103    #[serde(rename = "audio")]
104    Audio {
105        data: String,
106        #[serde(rename = "mimeType")]
107        mime_type: String,
108    },
109    /// A link to a resource the client may fetch or subscribe to.
110    #[serde(rename = "resource_link")]
111    ResourceLink {
112        uri: String,
113        #[serde(default)]
114        name: String,
115        #[serde(default, skip_serializing_if = "Option::is_none")]
116        description: Option<String>,
117        #[serde(rename = "mimeType", default, skip_serializing_if = "Option::is_none")]
118        mime_type: Option<String>,
119    },
120    /// An embedded resource, whose payload is nested under `resource`.
121    #[serde(rename = "resource")]
122    Resource { resource: EmbeddedResource },
123    /// Any content block this client does not model.
124    ///
125    /// Internally-tagged enums can't carry the original payload in a catch-all
126    /// arm, so the block's data is dropped - but that is the point: without
127    /// this variant a single unrecognized block (a future content type, or a
128    /// vendor extension) fails the *entire* tool result. Callers skip these and
129    /// warn.
130    #[serde(other)]
131    Unknown,
132}
133/// MCP protocol revisions this client understands, newest first.
134///
135/// The client offers the newest and adopts whatever the server echoes back.
136/// Pinning a single old revision - as this used to, with `2024-11-05`
137/// hardcoded - locks every connection to the oldest dialect and, on HTTP,
138/// actively misdeclares the connection: the streamable transport postdates
139/// that revision entirely.
140pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] =
141    &["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
142
143/// The revision offered in `initialize`.
144pub const PREFERRED_PROTOCOL_VERSION: &str = SUPPORTED_PROTOCOL_VERSIONS[0];
145
146/// Client for communicating with MCP tool providers over any transport.
147pub struct MCPClient {
148    /// The underlying message channel (stdio or HTTP).
149    transport: Box<dyn Transport>,
150    /// Next request ID
151    next_id: AtomicU64,
152    /// How long to wait for a response to an ordinary request.
153    request_timeout: Duration,
154    /// Server capabilities after initialization
155    capabilities: Option<ServerCapabilities>,
156    /// The protocol revision agreed during `initialize`.
157    protocol_version: Option<String>,
158    /// Cached tool list from the server
159    cached_tools: Vec<ToolMetadata>,
160}
161
162impl MCPClient {
163    /// Build a client over an already-constructed transport.
164    pub(crate) fn new(transport: Box<dyn Transport>) -> Self {
165        Self {
166            transport,
167            next_id: AtomicU64::new(1),
168            request_timeout: DEFAULT_REQUEST_TIMEOUT,
169            capabilities: None,
170            protocol_version: None,
171            cached_tools: Vec::new(),
172        }
173    }
174
175    /// Spawn an MCP server as a child process and talk to it over stdio.
176    pub async fn spawn(
177        command: &str,
178        args: &[&str],
179        env: &HashMap<String, String>,
180    ) -> anyhow::Result<Self> {
181        let transport = StdioTransport::spawn(command, args, env).await?;
182        Ok(Self::new(Box::new(transport)))
183    }
184
185    /// Connect to an MCP server over HTTP.
186    pub fn connect_http(
187        url: &str,
188        headers: &HashMap<String, String>,
189        allow_env: &[String],
190    ) -> anyhow::Result<Self> {
191        let transport = HttpTransport::new(url, headers, allow_env)?;
192        Ok(Self::new(Box::new(transport)))
193    }
194
195    /// Attach a refresher used to re-authenticate on a mid-session `401`.
196    ///
197    /// Only the HTTP transport acts on it; stdio ignores it. The daemon sets
198    /// this for an OAuth-backed HTTP server so a long run whose token expires
199    /// keeps working.
200    pub fn set_refresher(
201        &mut self,
202        refresher: std::sync::Arc<dyn crate::transport::BearerRefresher>,
203    ) {
204        self.transport.set_bearer_refresher(refresher);
205    }
206
207    /// Build a client for a configured server, over whichever transport the
208    /// entry describes.
209    ///
210    /// Callers above this point - discovery, execution, the tool registry -
211    /// never learn which one it turned out to be.
212    pub async fn from_config(config: &MCPServerConfig) -> anyhow::Result<Self> {
213        Self::from_config_with_auth(config, None, &[]).await
214    }
215
216    /// [`Self::from_config`] with a resolved `Authorization` header injected for
217    /// an HTTP server.
218    ///
219    /// `auth_header` is the `(name, value)` pair from
220    /// [`crate::OAuthClient::authorization_header`]; it is layered on top of the
221    /// config's static headers (and wins on a clash, since a live token should
222    /// override a stale hard-coded one). Ignored for stdio servers, which carry
223    /// no HTTP headers.
224    pub async fn from_config_with_auth(
225        config: &MCPServerConfig,
226        auth_header: Option<(String, String)>,
227        allow_env: &[String],
228    ) -> anyhow::Result<Self> {
229        match config.resolve()? {
230            ResolvedTransport::Stdio { command, args, env } => {
231                let args: Vec<&str> = args.iter().map(String::as_str).collect();
232                Self::spawn(command, &args, env).await
233            }
234            ResolvedTransport::Http { url, headers } => {
235                let mut headers = headers.clone();
236                if let Some((name, value)) = auth_header {
237                    headers.insert(name, value);
238                }
239                Self::connect_http(url, &headers, allow_env)
240            }
241        }
242    }
243
244    /// Connect to the MCP server by sending initialize and initialized messages.
245    pub async fn connect(&mut self) -> anyhow::Result<()> {
246        tracing::info!("Initializing MCP connection");
247
248        let init_params = serde_json::json!({
249            "protocolVersion": PREFERRED_PROTOCOL_VERSION,
250            "capabilities": {},
251            "clientInfo": {
252                "name": "leviath",
253                "version": env!("CARGO_PKG_VERSION")
254            }
255        });
256
257        // Tighter than an ordinary request: a server that hasn't finished its
258        // handshake in this long is broken, not busy, and it is holding up an
259        // agent's startup.
260        let result = self
261            .request_with_timeout("initialize", init_params, DEFAULT_CONNECT_TIMEOUT)
262            .await?;
263
264        // Parse server capabilities
265        let capabilities: ServerCapabilities = if let Some(caps) = result.get("capabilities") {
266            serde_json::from_value(caps.clone()).unwrap_or_default()
267        } else {
268            ServerCapabilities::default()
269        };
270        self.capabilities = Some(capabilities);
271        let version = negotiated_version(result.get("protocolVersion"));
272        self.protocol_version = Some(version.clone());
273
274        // Send initialized notification
275        self.send_notification("notifications/initialized", serde_json::json!({}))
276            .await?;
277
278        tracing::info!(version = %version, "MCP connection established");
279        Ok(())
280    }
281
282    /// List available tools from the server, following `nextCursor` pagination.
283    ///
284    /// `tools/list` is a paginated operation. Reading only the first response
285    /// silently exposes just the first page of a large server's catalogue, so
286    /// this loops until the server stops returning a cursor, bounded by an
287    /// internal page limit so a server that returns a cursor forever can't spin
288    /// the loop.
289    pub async fn list_tools(&mut self) -> anyhow::Result<Vec<ToolMetadata>> {
290        tracing::debug!("Listing MCP tools");
291
292        let mut tools: Vec<ToolMetadata> = Vec::new();
293        let mut cursor: Option<String> = None;
294
295        for page in 0..MAX_TOOL_PAGES {
296            let params = match &cursor {
297                Some(c) => serde_json::json!({ "cursor": c }),
298                None => serde_json::json!({}),
299            };
300            let result = self.send_request("tools/list", params).await?;
301
302            let tools_value = result.get("tools").cloned().unwrap_or(Value::Array(vec![]));
303            let page_tools: Vec<ToolMetadata> = serde_json::from_value(tools_value)
304                .map_err(|e| anyhow::anyhow!("Failed to parse tools list: {}", e))?;
305            tools.extend(page_tools);
306
307            cursor = result
308                .get("nextCursor")
309                .and_then(Value::as_str)
310                .map(str::to_string);
311            if cursor.is_none() {
312                break;
313            }
314            if page + 1 == MAX_TOOL_PAGES {
315                tracing::warn!(
316                    pages = MAX_TOOL_PAGES,
317                    "MCP server still returned a tools/list cursor at the page \
318                     limit - stopping; some tools may be missing"
319                );
320            }
321        }
322
323        self.cached_tools = tools.clone();
324        // Bound in a plain `let` rather than inlined as a tracing field: as a
325        // field it is only evaluated when a subscriber is installed, so its
326        // coverage would depend on test ordering (see discovery.rs).
327        let count = tools.len();
328        tracing::debug!(count, "Discovered MCP tools");
329        Ok(tools)
330    }
331
332    /// Call a tool on the server.
333    pub async fn call_tool(&mut self, name: &str, arguments: Value) -> anyhow::Result<ToolResult> {
334        tracing::debug!(tool = %name, "Calling MCP tool");
335
336        let params = serde_json::json!({
337            "name": name,
338            "arguments": arguments,
339        });
340
341        let result = self.send_request("tools/call", params).await?;
342
343        let tool_result: ToolResult = serde_json::from_value(result)
344            .map_err(|e| anyhow::anyhow!("Failed to parse tool result: {}", e))?;
345
346        Ok(tool_result)
347    }
348
349    /// Shutdown the MCP server.
350    ///
351    /// Always succeeds: a dead or unresponsive server must never block cleanup.
352    pub async fn shutdown(&mut self) -> anyhow::Result<()> {
353        tracing::info!("Shutting down MCP server");
354        let _ = self.transport.close().await;
355        Ok(())
356    }
357
358    /// Get the server capabilities (available after connect).
359    pub fn capabilities(&self) -> Option<&ServerCapabilities> {
360        self.capabilities.as_ref()
361    }
362
363    /// The protocol revision agreed with the server (available after connect).
364    pub fn protocol_version(&self) -> Option<&str> {
365        self.protocol_version.as_deref()
366    }
367
368    /// Get the cached tool list.
369    pub fn cached_tools(&self) -> &[ToolMetadata] {
370        &self.cached_tools
371    }
372
373    /// Send a JSON-RPC request and wait for a response.
374    async fn send_request(&mut self, method: &str, params: Value) -> anyhow::Result<Value> {
375        self.request_with_timeout(method, params, self.request_timeout)
376            .await
377    }
378
379    /// [`Self::send_request`] with an explicit deadline.
380    async fn request_with_timeout(
381        &mut self,
382        method: &str,
383        params: Value,
384        timeout: Duration,
385    ) -> anyhow::Result<Value> {
386        let id = self.next_id.fetch_add(1, Ordering::SeqCst);
387        let request = JsonRpcRequest::request(id, method, params);
388        self.transport
389            .send_request(&request, timeout)
390            .await?
391            .into_result()
392    }
393
394    /// Send a JSON-RPC notification (fire-and-forget, no response expected).
395    async fn send_notification(&mut self, method: &str, params: Value) -> anyhow::Result<()> {
396        let request = JsonRpcRequest::notification(method, params);
397        self.transport.send_notification(&request).await
398    }
399}
400
401/// Decide which protocol revision is in force after `initialize`.
402///
403/// A server that echoes a revision we know wins outright. One that echoes
404/// something unrecognized is *still* honored rather than rejected: the value
405/// only feeds the `MCP-Protocol-Version` header, and refusing to talk to a
406/// server that speaks a newer revision than this client was compiled against
407/// would break connections that otherwise work fine.
408fn negotiated_version(echoed: Option<&Value>) -> String {
409    match echoed.and_then(Value::as_str) {
410        Some(version) => {
411            if !SUPPORTED_PROTOCOL_VERSIONS.contains(&version) {
412                tracing::warn!(
413                    version = %version,
414                    "MCP server negotiated an unrecognized protocol revision - continuing"
415                );
416            }
417            version.to_string()
418        }
419        None => {
420            // Servers predating version negotiation omit the field.
421            tracing::debug!("MCP server echoed no protocolVersion - assuming the offered one");
422            PREFERRED_PROTOCOL_VERSION.to_string()
423        }
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use crate::test_support::always_on_tracing_guard;
431
432    // ── Additional coverage tests ──────────────────────────────────────────
433
434    #[test]
435    fn test_server_capabilities_default() {
436        let caps = ServerCapabilities::default();
437        assert!(caps.tools.is_none());
438    }
439
440    #[test]
441    fn test_tools_capability_default() {
442        let cap = ToolsCapability::default();
443        assert!(cap.list_changed.is_none());
444    }
445
446    #[test]
447    fn test_server_capabilities_serialization() {
448        let caps = ServerCapabilities {
449            tools: Some(ToolsCapability {
450                list_changed: Some(true),
451            }),
452        };
453        let json = serde_json::to_string(&caps).unwrap();
454        assert!(json.contains("listChanged"));
455        assert!(json.contains("true"));
456
457        let deserialized: ServerCapabilities = serde_json::from_str(&json).unwrap();
458        assert!(deserialized.tools.unwrap().list_changed.unwrap());
459    }
460
461    #[test]
462    fn test_tool_result_serialization() {
463        let result = ToolResult {
464            content: vec![ToolResultContent::Text {
465                text: "Hello".to_string(),
466            }],
467            structured_content: None,
468            is_error: false,
469        };
470        let json = serde_json::to_string(&result).unwrap();
471        assert!(json.contains("Hello"));
472        // Wire name is `isError`, not `is_error` - a mismatch here meant every
473        // failing tool call deserialized as a success.
474        assert!(json.contains("\"isError\":false"), "got: {json}");
475        assert!(!json.contains("is_error"), "got: {json}");
476    }
477
478    #[test]
479    fn test_tool_result_with_error() {
480        let result = ToolResult {
481            content: vec![ToolResultContent::Text {
482                text: "Something went wrong".to_string(),
483            }],
484            structured_content: None,
485            is_error: true,
486        };
487        assert!(result.is_error);
488        assert_eq!(result.content.len(), 1);
489    }
490
491    #[test]
492    fn test_tool_result_content_text() {
493        let content = ToolResultContent::Text {
494            text: "result text".to_string(),
495        };
496        let json = serde_json::to_string(&content).unwrap();
497        assert!(json.contains("result text"));
498        assert!(json.contains("\"type\":\"text\""));
499    }
500
501    #[test]
502    fn test_tool_result_content_image() {
503        let content = ToolResultContent::Image {
504            data: "base64data".to_string(),
505            mime_type: "image/png".to_string(),
506        };
507        let json = serde_json::to_string(&content).unwrap();
508        assert!(json.contains("base64data"));
509        assert!(json.contains("image/png"));
510    }
511
512    #[test]
513    fn test_tool_result_content_resource() {
514        let content = ToolResultContent::Resource {
515            resource: EmbeddedResource {
516                uri: "file:///tmp/test.txt".to_string(),
517                text: Some("file contents".to_string()),
518                blob: None,
519                mime_type: None,
520            },
521        };
522        let json = serde_json::to_string(&content).unwrap();
523        assert!(json.contains("file:///tmp/test.txt"));
524        assert!(json.contains("file contents"));
525    }
526
527    #[test]
528    fn test_tool_result_content_resource_no_text() {
529        let content = ToolResultContent::Resource {
530            resource: EmbeddedResource {
531                uri: "file:///tmp/test.txt".to_string(),
532                text: None,
533                blob: None,
534                mime_type: None,
535            },
536        };
537        let json = serde_json::to_string(&content).unwrap();
538        assert!(json.contains("file:///tmp/test.txt"));
539    }
540
541    #[test]
542    fn test_tool_result_deserialization() {
543        let json = r#"{"content":[{"type":"text","text":"Hello"}],"is_error":false}"#;
544        let result: ToolResult = serde_json::from_str(json).unwrap();
545        assert!(!result.is_error);
546        assert_eq!(result.content.len(), 1);
547    }
548
549    #[test]
550    fn test_tool_result_deserialization_missing_is_error() {
551        let json = r#"{"content":[{"type":"text","text":"Hello"}]}"#;
552        let result: ToolResult = serde_json::from_str(json).unwrap();
553        assert!(!result.is_error); // defaults to false
554    }
555
556    #[test]
557    fn test_tool_result_multiple_content() {
558        let result = ToolResult {
559            content: vec![
560                ToolResultContent::Text {
561                    text: "line 1".to_string(),
562                },
563                ToolResultContent::Text {
564                    text: "line 2".to_string(),
565                },
566            ],
567            structured_content: None,
568            is_error: false,
569        };
570        assert_eq!(result.content.len(), 2);
571    }
572
573    #[test]
574    fn test_tool_result_clone() {
575        let result = ToolResult {
576            content: vec![ToolResultContent::Text {
577                text: "test".to_string(),
578            }],
579            structured_content: None,
580            is_error: true,
581        };
582        let cloned = result.clone();
583        assert!(cloned.is_error);
584        assert_eq!(cloned.content.len(), 1);
585    }
586
587    #[test]
588    fn test_server_capabilities_clone() {
589        let caps = ServerCapabilities {
590            tools: Some(ToolsCapability {
591                list_changed: Some(true),
592            }),
593        };
594        let cloned = caps.clone();
595        assert!(cloned.tools.unwrap().list_changed.unwrap());
596    }
597
598    // ─── ToolResult additional tests ──────────────────────────────────────
599
600    #[test]
601    fn test_tool_result_empty_content() {
602        let result = ToolResult {
603            content: vec![],
604            structured_content: None,
605            is_error: false,
606        };
607        assert!(result.content.is_empty());
608        assert!(!result.is_error);
609    }
610
611    #[test]
612    fn test_tool_result_mixed_content_types() {
613        let result = ToolResult {
614            content: vec![
615                ToolResultContent::Text {
616                    text: "hello".to_string(),
617                },
618                ToolResultContent::Image {
619                    data: "base64".to_string(),
620                    mime_type: "image/jpeg".to_string(),
621                },
622                ToolResultContent::Resource {
623                    resource: EmbeddedResource {
624                        uri: "file:///test".to_string(),
625                        text: Some("content".to_string()),
626                        blob: None,
627                        mime_type: None,
628                    },
629                },
630            ],
631            structured_content: None,
632            is_error: false,
633        };
634        assert_eq!(result.content.len(), 3);
635        let json = serde_json::to_string(&result).unwrap();
636        let back: ToolResult = serde_json::from_str(&json).unwrap();
637        assert_eq!(back.content.len(), 3);
638    }
639
640    // ─── ServerCapabilities deserialization ────────────────────────────────
641
642    #[test]
643    fn test_server_capabilities_from_empty_json() {
644        let caps: ServerCapabilities = serde_json::from_str("{}").unwrap();
645        assert!(caps.tools.is_none());
646    }
647
648    #[test]
649    fn test_server_capabilities_with_tools() {
650        let json = r#"{"tools":{"listChanged":false}}"#;
651        let caps: ServerCapabilities = serde_json::from_str(json).unwrap();
652        let tools = caps.tools.unwrap();
653        assert_eq!(tools.list_changed, Some(false));
654    }
655
656    #[test]
657    fn test_server_capabilities_with_null_tools() {
658        let json = r#"{"tools":null}"#;
659        let caps: ServerCapabilities = serde_json::from_str(json).unwrap();
660        assert!(caps.tools.is_none());
661    }
662
663    // ─── ToolsCapability serde ────────────────────────────────────────────
664
665    #[test]
666    fn test_tools_capability_with_list_changed_true() {
667        let cap = ToolsCapability {
668            list_changed: Some(true),
669        };
670        let json = serde_json::to_string(&cap).unwrap();
671        assert!(json.contains("true"));
672        let back: ToolsCapability = serde_json::from_str(&json).unwrap();
673        assert_eq!(back.list_changed, Some(true));
674    }
675
676    #[test]
677    fn test_tools_capability_no_list_changed() {
678        let cap = ToolsCapability { list_changed: None };
679        let json = serde_json::to_string(&cap).unwrap();
680        let back: ToolsCapability = serde_json::from_str(&json).unwrap();
681        assert!(back.list_changed.is_none());
682    }
683
684    // ─── ToolResultContent deserialization ─────────────────────────────────
685
686    #[test]
687    fn test_tool_result_content_text_deserialization() {
688        let json = r#"{"type":"text","text":"hello world"}"#;
689        let content: ToolResultContent = serde_json::from_str(json).unwrap();
690        assert_eq!(
691            content,
692            ToolResultContent::Text {
693                text: "hello world".to_string()
694            }
695        );
696    }
697
698    #[test]
699    fn test_tool_result_content_image_deserialization() {
700        let json = r#"{"type":"image","data":"abc123","mimeType":"image/png"}"#;
701        let content: ToolResultContent = serde_json::from_str(json).unwrap();
702        assert_eq!(
703            content,
704            ToolResultContent::Image {
705                data: "abc123".to_string(),
706                mime_type: "image/png".to_string(),
707            }
708        );
709    }
710
711    #[test]
712    fn test_tool_result_content_resource_deserialization() {
713        // The spec nests the payload under `resource`; it is not inlined on the
714        // content block.
715        let json = r#"{"type":"resource","resource":{"uri":"file:///tmp/x","text":"data"}}"#;
716        let content: ToolResultContent = serde_json::from_str(json).unwrap();
717        assert_eq!(
718            content,
719            ToolResultContent::Resource {
720                resource: EmbeddedResource {
721                    uri: "file:///tmp/x".to_string(),
722                    text: Some("data".to_string()),
723                    blob: None,
724                    mime_type: None,
725                },
726            }
727        );
728    }
729
730    // ─── filter_env additional ────────────────────────────────────────────
731
732    // ─── JsonRpcRequest serialization ───────────────────────────────────
733
734    // ─── JsonRpcResponse deserialization ─────────────────────────────────
735
736    // ─── ToolResult complex cases ───────────────────────────────────────
737
738    #[test]
739    fn test_tool_result_json_roundtrip() {
740        let result = ToolResult {
741            content: vec![
742                ToolResultContent::Text {
743                    text: "line1".to_string(),
744                },
745                ToolResultContent::Image {
746                    data: "abc".to_string(),
747                    mime_type: "image/png".to_string(),
748                },
749                ToolResultContent::Resource {
750                    resource: EmbeddedResource {
751                        uri: "file:///x".to_string(),
752                        text: Some("data".to_string()),
753                        blob: None,
754                        mime_type: None,
755                    },
756                },
757            ],
758            structured_content: None,
759            is_error: false,
760        };
761        let json = serde_json::to_string(&result).unwrap();
762        let back: ToolResult = serde_json::from_str(&json).unwrap();
763        assert_eq!(back.content.len(), 3);
764        assert!(!back.is_error);
765    }
766
767    // ─── filter_env: mixed sensitive and safe ───────────────────────────
768
769    // ─── ServerCapabilities with empty tools ────────────────────────────
770
771    #[test]
772    fn test_server_capabilities_empty_tools_object() {
773        let json = r#"{"tools":{}}"#;
774        let caps: ServerCapabilities = serde_json::from_str(json).unwrap();
775        let tools = caps.tools.unwrap();
776        assert!(tools.list_changed.is_none());
777    }
778
779    // ─── ToolResultContent: edge cases ──────────────────────────────────
780
781    #[test]
782    fn test_tool_result_content_text_empty() {
783        let content = ToolResultContent::Text {
784            text: "".to_string(),
785        };
786        let json = serde_json::to_string(&content).unwrap();
787        let back: ToolResultContent = serde_json::from_str(&json).unwrap();
788        assert_eq!(
789            back,
790            ToolResultContent::Text {
791                text: "".to_string()
792            }
793        );
794    }
795
796    #[test]
797    fn test_tool_result_content_image_empty_data() {
798        let content = ToolResultContent::Image {
799            data: "".to_string(),
800            mime_type: "".to_string(),
801        };
802        let json = serde_json::to_string(&content).unwrap();
803        assert!(json.contains("\"type\":\"image\""));
804    }
805
806    #[test]
807    fn test_tool_result_content_resource_empty_uri() {
808        let content = ToolResultContent::Resource {
809            resource: EmbeddedResource {
810                uri: "".to_string(),
811                text: None,
812                blob: None,
813                mime_type: None,
814            },
815        };
816        let json = serde_json::to_string(&content).unwrap();
817        assert!(json.contains("\"type\":\"resource\""));
818    }
819
820    // ─── MCPClient spawn / connect / list_tools / call_tool / shutdown ────────
821    // We use Python as a minimal in-process JSON-RPC 2.0 stub server that reads
822    // one request per line and responds with a canned reply.
823
824    /// Spawn a Python-backed stub MCP server.  The script reads JSON-RPC lines
825    /// from stdin and writes canned responses to stdout.
826    async fn spawn_stub_client(script: &str) -> MCPClient {
827        MCPClient::spawn("python3", &["-c", script], &HashMap::new())
828            .await
829            .expect("Failed to spawn stub MCP server")
830    }
831
832    // Python script that answers initialize then tools/list then tools/call
833    const STUB_INIT_LIST_CALL: &str = r#"
834import sys, json
835
836def respond(id, result):
837    msg = json.dumps({"jsonrpc": "2.0", "id": id, "result": result})
838    sys.stdout.write(msg + "\n")
839    sys.stdout.flush()
840
841for line in sys.stdin:
842    line = line.strip()
843    if not line:
844        continue
845    req = json.loads(line)
846    method = req.get("method", "")
847    id_ = req.get("id")
848    if method == "initialize":
849        respond(id_, {"capabilities": {"tools": {"listChanged": True}}, "protocolVersion": "2024-11-05"})
850    elif method == "notifications/initialized":
851        pass  # notification -- no response
852    elif method == "tools/list":
853        respond(id_, {"tools": [{"name": "echo", "description": "echo tool", "inputSchema": {}}]})
854    elif method == "tools/call":
855        respond(id_, {"content": [{"type": "text", "text": "hello from tool"}], "isError": False})
856    elif method == "notifications/cancelled":
857        pass
858    else:
859        respond(id_, {"error": {"code": -32601, "message": "method not found"}})
860"#;
861
862    // Script that always returns a JSON-RPC error for every request
863    const STUB_ERROR_SERVER: &str = r#"
864import sys, json
865
866for line in sys.stdin:
867    line = line.strip()
868    if not line:
869        continue
870    req = json.loads(line)
871    id_ = req.get("id")
872    if id_ is not None:
873        msg = json.dumps({"jsonrpc": "2.0", "id": id_, "error": {"code": -32600, "message": "server error"}})
874        sys.stdout.write(msg + "\n")
875        sys.stdout.flush()
876"#;
877
878    // Script that closes stdout immediately (simulates process exit mid-request)
879    const STUB_CLOSE_IMMEDIATELY: &str = r#"
880import sys
881sys.stdout.close()
882"#;
883
884    // Script that responds to initialize then closes its own stdin fd so the parent's
885    // notification flush gets EPIPE. The process stays alive (sleeping) so the
886    // process itself doesn't exit before our write attempt.
887    //
888    // Ordering note: os.close(0) MUST happen BEFORE the response is written to
889    // stdout, not after. Closing after flushing the response is NOT a
890    // happens-before relationship from our side: our client can finish reading
891    // the response and race ahead to the notification write while the child is
892    // still merely *about* to execute the next line, before the close(0)
893    // syscall has actually completed. That race is flaky (passes on some CI
894    // runners, fails on others, depending on process-scheduling speed).
895    // Closing stdin first guarantees
896    // the read end is fully closed before the response bytes can even be
897    // sent, which our client necessarily observes only after they're sent --
898    // so by the time we read the response and attempt the notification
899    // write, the close has unconditionally already happened.
900    const STUB_INIT_THEN_CLOSE_STDIN: &str = r#"
901import sys, json, os
902for line in sys.stdin:
903    line = line.strip()
904    if not line:
905        continue
906    req = json.loads(line)
907    if req.get("method") == "initialize":
908        id_ = req.get("id")
909        os.close(0)
910        result = {"capabilities": {}, "protocolVersion": "2024-11-05"}
911        msg = json.dumps({"jsonrpc": "2.0", "id": id_, "result": result})
912        sys.stdout.write(msg + "\n")
913        sys.stdout.flush()
914        import time; time.sleep(10)
915        break
916"#;
917
918    #[tokio::test]
919    async fn test_mcp_client_spawn_succeeds() {
920        let _guard = always_on_tracing_guard();
921        let _client = spawn_stub_client(STUB_INIT_LIST_CALL).await;
922        // If we got here, spawn worked
923    }
924
925    #[tokio::test]
926    async fn test_mcp_client_connect_parses_capabilities() {
927        let _guard = always_on_tracing_guard();
928        let mut client = spawn_stub_client(STUB_INIT_LIST_CALL).await;
929        client.connect().await.expect("connect should succeed");
930
931        let caps = client.capabilities().expect("should have capabilities");
932        assert!(caps.tools.is_some());
933        assert_eq!(caps.tools.as_ref().unwrap().list_changed, Some(true));
934    }
935
936    #[tokio::test]
937    async fn test_mcp_client_capabilities_before_connect_is_none() {
938        let client = spawn_stub_client(STUB_INIT_LIST_CALL).await;
939        // Before connect, capabilities should be None
940        assert!(client.capabilities().is_none());
941    }
942
943    #[tokio::test]
944    async fn test_connect_fails_when_notification_write_errors() {
945        let _guard = always_on_tracing_guard();
946        // Server responds to initialize then closes its stdin, causing our
947        // notification flush to fail with EPIPE. This covers the `?` error
948        // propagation path in connect() after send_notification.
949        let mut client = spawn_stub_client(STUB_INIT_THEN_CLOSE_STDIN).await;
950        let result = client.connect().await;
951        assert!(result.is_err());
952    }
953
954    #[tokio::test]
955    async fn test_mcp_client_cached_tools_before_list_is_empty() {
956        let client = spawn_stub_client(STUB_INIT_LIST_CALL).await;
957        assert!(client.cached_tools().is_empty());
958    }
959
960    #[tokio::test]
961    async fn test_mcp_client_list_tools_returns_tools() {
962        let _guard = always_on_tracing_guard();
963        let mut client = spawn_stub_client(STUB_INIT_LIST_CALL).await;
964        client.connect().await.unwrap();
965
966        let tools = client
967            .list_tools()
968            .await
969            .expect("list_tools should succeed");
970        assert_eq!(tools.len(), 1);
971        assert_eq!(tools[0].name, "echo");
972
973        // cached_tools() should now return them too
974        assert_eq!(client.cached_tools().len(), 1);
975        assert_eq!(client.cached_tools()[0].name, "echo");
976    }
977
978    #[tokio::test]
979    async fn test_mcp_client_call_tool_returns_result() {
980        let _guard = always_on_tracing_guard();
981        let mut client = spawn_stub_client(STUB_INIT_LIST_CALL).await;
982        client.connect().await.unwrap();
983        // Consume the tools/list response first
984        client.list_tools().await.unwrap();
985
986        let result = client
987            .call_tool("echo", serde_json::json!({"msg": "hi"}))
988            .await
989            .expect("call_tool should succeed");
990
991        assert_eq!(result.content.len(), 1);
992        assert_eq!(
993            result.content[0],
994            ToolResultContent::Text {
995                text: "hello from tool".to_string()
996            }
997        );
998    }
999
1000    #[tokio::test]
1001    async fn test_mcp_client_shutdown_succeeds() {
1002        let _guard = always_on_tracing_guard();
1003        let mut client = spawn_stub_client(STUB_INIT_LIST_CALL).await;
1004        client.connect().await.unwrap();
1005        // Shutdown should not fail even if the process is still running
1006        client.shutdown().await.expect("shutdown should succeed");
1007    }
1008
1009    #[tokio::test]
1010    async fn test_mcp_client_shutdown_with_dead_process() {
1011        let mut client = spawn_stub_client(STUB_CLOSE_IMMEDIATELY).await;
1012        // Process closed stdout immediately; shutdown should still succeed
1013        client
1014            .shutdown()
1015            .await
1016            .expect("shutdown should be graceful");
1017    }
1018
1019    // ─── send_request/send_notification: real write/flush I/O errors ───────
1020    //
1021    // `writer` is a `BufWriter`, so a small `write_all` just appends to its
1022    // in-memory buffer without touching the OS - the *real* write only
1023    // happens on `flush()`, which is where a broken pipe actually surfaces.
1024    // Killing and reaping the child first guarantees the pipe's read end is
1025    // gone, so these are deterministic, not racy. A payload large enough to
1026    // exceed `BufWriter`'s default 8KB capacity forces `write_all` itself to
1027    // bypass buffering and write directly, surfacing the error there instead.
1028
1029    #[tokio::test]
1030    async fn test_mcp_client_server_error_propagates() {
1031        let mut client = spawn_stub_client(STUB_ERROR_SERVER).await;
1032        // initialize sends a request and the error server will return an error response
1033        let err = client.connect().await;
1034        assert!(err.is_err());
1035        assert!(err.unwrap_err().to_string().contains("server error"));
1036    }
1037
1038    #[tokio::test]
1039    async fn test_mcp_client_connect_with_no_capabilities_field() {
1040        // Server returns initialize result without a "capabilities" key
1041        let script = r#"
1042import sys, json
1043
1044for line in sys.stdin:
1045    line = line.strip()
1046    if not line:
1047        continue
1048    req = json.loads(line)
1049    method = req.get("method", "")
1050    id_ = req.get("id")
1051    if method == "initialize":
1052        msg = json.dumps({"jsonrpc": "2.0", "id": id_, "result": {"protocolVersion": "2024-11-05"}})
1053        sys.stdout.write(msg + "\n")
1054        sys.stdout.flush()
1055    elif method == "notifications/initialized":
1056        pass
1057    elif method == "notifications/cancelled":
1058        pass
1059"#;
1060        let mut client = spawn_stub_client(script).await;
1061        // Should succeed with default (empty) capabilities
1062        client.connect().await.expect("connect should succeed");
1063        let caps = client.capabilities().unwrap();
1064        assert!(caps.tools.is_none());
1065    }
1066
1067    #[tokio::test]
1068    async fn test_mcp_client_list_tools_missing_tools_key_returns_empty() {
1069        // Server returns initialize ok and tools/list without "tools" key -> empty list
1070        let script = r#"
1071import sys, json
1072
1073for line in sys.stdin:
1074    line = line.strip()
1075    if not line:
1076        continue
1077    req = json.loads(line)
1078    method = req.get("method", "")
1079    id_ = req.get("id")
1080    if method == "initialize":
1081        msg = json.dumps({"jsonrpc": "2.0", "id": id_, "result": {"capabilities": {}}})
1082        sys.stdout.write(msg + "\n")
1083        sys.stdout.flush()
1084    elif method == "notifications/initialized":
1085        pass
1086    elif method == "tools/list":
1087        # Return result without "tools" key
1088        msg = json.dumps({"jsonrpc": "2.0", "id": id_, "result": {}})
1089        sys.stdout.write(msg + "\n")
1090        sys.stdout.flush()
1091    elif method == "notifications/cancelled":
1092        pass
1093"#;
1094        let mut client = spawn_stub_client(script).await;
1095        client.connect().await.unwrap();
1096        let tools = client
1097            .list_tools()
1098            .await
1099            .expect("list_tools should succeed");
1100        assert!(tools.is_empty());
1101    }
1102
1103    #[tokio::test]
1104    async fn test_mcp_client_list_tools_malformed_response_is_error() {
1105        // Server returns valid initialize but tools/list with bad tools array
1106        let script = r#"
1107import sys, json
1108
1109for line in sys.stdin:
1110    line = line.strip()
1111    if not line:
1112        continue
1113    req = json.loads(line)
1114    method = req.get("method", "")
1115    id_ = req.get("id")
1116    if method == "initialize":
1117        msg = json.dumps({"jsonrpc": "2.0", "id": id_, "result": {"capabilities": {}}})
1118        sys.stdout.write(msg + "\n")
1119        sys.stdout.flush()
1120    elif method == "notifications/initialized":
1121        pass
1122    elif method == "tools/list":
1123        # Return tools as a string instead of an array -- triggers parse error
1124        msg = json.dumps({"jsonrpc": "2.0", "id": id_, "result": {"tools": "not_an_array"}})
1125        sys.stdout.write(msg + "\n")
1126        sys.stdout.flush()
1127    elif method == "notifications/cancelled":
1128        pass
1129"#;
1130        let mut client = spawn_stub_client(script).await;
1131        client.connect().await.unwrap();
1132        let err = client.list_tools().await;
1133        assert!(err.is_err());
1134        assert!(err.unwrap_err().to_string().contains("parse"));
1135    }
1136
1137    #[tokio::test]
1138    async fn test_mcp_client_call_tool_malformed_result_is_error() {
1139        let script = r#"
1140import sys, json
1141
1142for line in sys.stdin:
1143    line = line.strip()
1144    if not line:
1145        continue
1146    req = json.loads(line)
1147    method = req.get("method", "")
1148    id_ = req.get("id")
1149    if method == "initialize":
1150        msg = json.dumps({"jsonrpc": "2.0", "id": id_, "result": {"capabilities": {}}})
1151        sys.stdout.write(msg + "\n")
1152        sys.stdout.flush()
1153    elif method == "notifications/initialized":
1154        pass
1155    elif method == "tools/call":
1156        # Return a result that can't be parsed as ToolResult
1157        msg = json.dumps({"jsonrpc": "2.0", "id": id_, "result": "bad_tool_result"})
1158        sys.stdout.write(msg + "\n")
1159        sys.stdout.flush()
1160    elif method == "notifications/cancelled":
1161        pass
1162"#;
1163        let mut client = spawn_stub_client(script).await;
1164        client.connect().await.unwrap();
1165        let err = client.call_tool("broken", serde_json::json!({})).await;
1166        assert!(err.is_err());
1167        assert!(err.unwrap_err().to_string().contains("parse"));
1168    }
1169
1170    #[tokio::test]
1171    async fn test_mcp_client_response_with_no_result_is_error() {
1172        // Server returns a valid JSON-RPC response with neither result nor error
1173        let script = r#"
1174import sys, json
1175
1176for line in sys.stdin:
1177    line = line.strip()
1178    if not line:
1179        continue
1180    req = json.loads(line)
1181    id_ = req.get("id")
1182    if id_ is not None:
1183        # No "result", no "error"
1184        msg = json.dumps({"jsonrpc": "2.0", "id": id_})
1185        sys.stdout.write(msg + "\n")
1186        sys.stdout.flush()
1187"#;
1188        let mut client = spawn_stub_client(script).await;
1189        let err = client.connect().await;
1190        assert!(err.is_err());
1191        assert!(err.unwrap_err().to_string().contains("no result"));
1192    }
1193
1194    // ─── Spec wire-format conformance ─────────────────────────────────────
1195    //
1196    // Each test here pins a field name or shape that is easy to get wrong on
1197    // the wire. The failures they guard against are mostly *silent* (a tool
1198    // error read as success) or total (one unrecognized block failing an
1199    // entire result), which is what makes real servers appear not to work.
1200
1201    #[test]
1202    fn tool_result_reads_is_error_from_camel_case_wire_name() {
1203        // The regression that motivated all of this: the wire field is
1204        // `isError`. Reading `is_error` never matched, so `#[serde(default)]`
1205        // silently produced `false` and every failed tool call was handed to
1206        // the model as a success.
1207        let json = r#"{"content":[{"type":"text","text":"boom"}],"isError":true}"#;
1208        let result: ToolResult = serde_json::from_str(json).unwrap();
1209        assert!(result.is_error, "isError:true must deserialize as an error");
1210    }
1211
1212    #[test]
1213    fn tool_result_snake_case_is_error_is_not_honored() {
1214        // Guards the inverse mistake: `is_error` is *not* a wire name, so a
1215        // payload using it must fall back to the default rather than being
1216        // silently honored as the error flag.
1217        let json = r#"{"content":[],"is_error":true}"#;
1218        let result: ToolResult = serde_json::from_str(json).unwrap();
1219        assert!(!result.is_error);
1220    }
1221
1222    #[test]
1223    fn tool_result_content_defaults_to_empty() {
1224        let json = r#"{"structuredContent":{"ok":true}}"#;
1225        let result: ToolResult = serde_json::from_str(json).unwrap();
1226        assert!(result.content.is_empty());
1227        assert_eq!(
1228            result.structured_content,
1229            Some(serde_json::json!({"ok": true}))
1230        );
1231    }
1232
1233    #[test]
1234    fn tool_result_structured_content_absent_is_none_and_omitted() {
1235        let result = ToolResult {
1236            content: vec![],
1237            structured_content: None,
1238            is_error: false,
1239        };
1240        let json = serde_json::to_string(&result).unwrap();
1241        assert!(!json.contains("structuredContent"), "got: {json}");
1242    }
1243
1244    #[test]
1245    fn tool_result_content_image_uses_mime_type_wire_name() {
1246        let content = ToolResultContent::Image {
1247            data: "abc".to_string(),
1248            mime_type: "image/png".to_string(),
1249        };
1250        let json = serde_json::to_string(&content).unwrap();
1251        assert!(json.contains("\"mimeType\":\"image/png\""), "got: {json}");
1252        assert!(!json.contains("mime_type"), "got: {json}");
1253    }
1254
1255    #[test]
1256    fn tool_result_content_audio_roundtrip() {
1257        let json = r#"{"type":"audio","data":"YWJj","mimeType":"audio/wav"}"#;
1258        let content: ToolResultContent = serde_json::from_str(json).unwrap();
1259        assert_eq!(
1260            content,
1261            ToolResultContent::Audio {
1262                data: "YWJj".to_string(),
1263                mime_type: "audio/wav".to_string(),
1264            }
1265        );
1266        let back: ToolResultContent =
1267            serde_json::from_str(&serde_json::to_string(&content).unwrap()).unwrap();
1268        assert_eq!(back, content);
1269    }
1270
1271    #[test]
1272    fn tool_result_content_resource_link_full() {
1273        let json = r#"{"type":"resource_link","uri":"file:///m.rs","name":"m.rs",
1274                       "description":"entry point","mimeType":"text/x-rust"}"#;
1275        let content: ToolResultContent = serde_json::from_str(json).unwrap();
1276        assert_eq!(
1277            content,
1278            ToolResultContent::ResourceLink {
1279                uri: "file:///m.rs".to_string(),
1280                name: "m.rs".to_string(),
1281                description: Some("entry point".to_string()),
1282                mime_type: Some("text/x-rust".to_string()),
1283            }
1284        );
1285    }
1286
1287    #[test]
1288    fn tool_result_content_resource_link_minimal_omits_optionals() {
1289        let content: ToolResultContent =
1290            serde_json::from_str(r#"{"type":"resource_link","uri":"file:///x"}"#).unwrap();
1291        assert_eq!(
1292            content,
1293            ToolResultContent::ResourceLink {
1294                uri: "file:///x".to_string(),
1295                name: String::new(),
1296                description: None,
1297                mime_type: None,
1298            }
1299        );
1300        let json = serde_json::to_string(&content).unwrap();
1301        assert!(!json.contains("description"), "got: {json}");
1302        assert!(!json.contains("mimeType"), "got: {json}");
1303    }
1304
1305    #[test]
1306    fn tool_result_content_embedded_resource_is_nested() {
1307        let json = r#"{"type":"resource","resource":{"uri":"file:///m.rs",
1308                       "mimeType":"text/x-rust","text":"fn main() {}"}}"#;
1309        let content: ToolResultContent = serde_json::from_str(json).unwrap();
1310        assert_eq!(
1311            content,
1312            ToolResultContent::Resource {
1313                resource: EmbeddedResource {
1314                    uri: "file:///m.rs".to_string(),
1315                    text: Some("fn main() {}".to_string()),
1316                    blob: None,
1317                    mime_type: Some("text/x-rust".to_string()),
1318                }
1319            }
1320        );
1321    }
1322
1323    #[test]
1324    fn tool_result_content_embedded_resource_binary_blob() {
1325        let json = r#"{"type":"resource","resource":{"uri":"file:///a.png","blob":"YWJj"}}"#;
1326        let content: ToolResultContent = serde_json::from_str(json).unwrap();
1327        assert_eq!(
1328            content,
1329            ToolResultContent::Resource {
1330                resource: EmbeddedResource {
1331                    uri: "file:///a.png".to_string(),
1332                    text: None,
1333                    blob: Some("YWJj".to_string()),
1334                    mime_type: None,
1335                }
1336            }
1337        );
1338        // Absent optionals stay absent on the way back out.
1339        let json = serde_json::to_string(&content).unwrap();
1340        assert!(!json.contains("text"), "got: {json}");
1341        assert!(!json.contains("mimeType"), "got: {json}");
1342    }
1343
1344    #[test]
1345    fn unknown_content_type_degrades_instead_of_failing_the_result() {
1346        // Without the catch-all arm a single unrecognized block - a future
1347        // content type or a vendor extension - makes the *whole* tool result
1348        // fail to parse, taking the usable blocks down with it.
1349        let json = r#"{"content":[
1350            {"type":"text","text":"keep me"},
1351            {"type":"hologram","payload":{"deeply":["nested"]}}
1352        ],"isError":false}"#;
1353        let result: ToolResult = serde_json::from_str(json).unwrap();
1354        assert_eq!(result.content.len(), 2);
1355        assert_eq!(
1356            result.content[0],
1357            ToolResultContent::Text {
1358                text: "keep me".to_string()
1359            }
1360        );
1361        assert_eq!(result.content[1], ToolResultContent::Unknown);
1362    }
1363
1364    #[test]
1365    fn unknown_content_serializes_without_panicking() {
1366        // The payload is unrecoverable by construction (internally-tagged
1367        // enums can't carry data in a catch-all), so this only has to be
1368        // lossy, not lossless.
1369        let json = serde_json::to_string(&ToolResultContent::Unknown).unwrap();
1370        assert!(json.contains("Unknown"), "got: {json}");
1371    }
1372
1373    // ─── tools/list pagination ────────────────────────────────────────────
1374
1375    /// Serves `tools/list` in `pages` pages of one tool each, returning a
1376    /// `nextCursor` on every page but the last.
1377    fn paginated_stub(pages: usize) -> String {
1378        format!(
1379            r#"
1380import sys, json
1381PAGES = {pages}
1382for line in sys.stdin:
1383    line = line.strip()
1384    if not line:
1385        continue
1386    req = json.loads(line)
1387    method, id_ = req.get("method", ""), req.get("id")
1388    if method == "initialize":
1389        res = {{"capabilities": {{}}, "protocolVersion": "2024-11-05"}}
1390    elif method == "tools/list":
1391        cursor = int((req.get("params") or {{}}).get("cursor", "0"))
1392        res = {{"tools": [{{"name": "tool%d" % cursor, "inputSchema": {{}}}}]}}
1393        if cursor + 1 < PAGES:
1394            res["nextCursor"] = str(cursor + 1)
1395    else:
1396        continue
1397    sys.stdout.write(json.dumps({{"jsonrpc": "2.0", "id": id_, "result": res}}) + "\n")
1398    sys.stdout.flush()
1399"#
1400        )
1401    }
1402
1403    #[tokio::test]
1404    async fn list_tools_follows_next_cursor_across_pages() {
1405        let _guard = always_on_tracing_guard();
1406        let script = paginated_stub(3);
1407        let mut client = spawn_stub_client(&script).await;
1408        client.connect().await.unwrap();
1409
1410        let tools = client
1411            .list_tools()
1412            .await
1413            .expect("list_tools should succeed");
1414        // Reading only the first response would have returned 1 of 3.
1415        let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
1416        assert_eq!(names, vec!["tool0", "tool1", "tool2"]);
1417        assert_eq!(client.cached_tools().len(), 3);
1418    }
1419
1420    #[tokio::test]
1421    async fn list_tools_stops_at_the_page_limit() {
1422        let _guard = always_on_tracing_guard();
1423        // Always returns a cursor: without the bound this never terminates.
1424        let script = paginated_stub(MAX_TOOL_PAGES + 10);
1425        let mut client = spawn_stub_client(&script).await;
1426        client.connect().await.unwrap();
1427
1428        let tools = client
1429            .list_tools()
1430            .await
1431            .expect("list_tools should succeed");
1432        assert_eq!(tools.len(), MAX_TOOL_PAGES);
1433    }
1434
1435    #[tokio::test]
1436    async fn transport_failure_propagates_out_of_a_request() {
1437        let _guard = always_on_tracing_guard();
1438        // A server that exits immediately: the transport itself fails, as
1439        // distinct from a server that answers with a JSON-RPC `error` member.
1440        // Both must surface as errors, by different paths.
1441        let mut client = spawn_stub_client("import sys\nsys.exit(0)\n").await;
1442        assert!(client.connect().await.is_err());
1443    }
1444
1445    // ─── protocol version negotiation ─────────────────────────────────────
1446
1447    #[test]
1448    fn preferred_version_is_the_newest_supported() {
1449        assert_eq!(PREFERRED_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS[0]);
1450        // Sorted newest-first, so [0] really is the newest.
1451        let mut sorted = SUPPORTED_PROTOCOL_VERSIONS.to_vec();
1452        sorted.sort_unstable_by(|a, b| b.cmp(a));
1453        assert_eq!(sorted, SUPPORTED_PROTOCOL_VERSIONS);
1454    }
1455
1456    #[test]
1457    fn negotiation_adopts_a_recognized_echo() {
1458        let _guard = always_on_tracing_guard();
1459        let echoed = serde_json::json!("2025-06-18");
1460        assert_eq!(negotiated_version(Some(&echoed)), "2025-06-18");
1461    }
1462
1463    #[test]
1464    fn negotiation_honors_an_unrecognized_echo() {
1465        let _guard = always_on_tracing_guard();
1466        // Refusing a revision newer than this client was compiled against
1467        // would break connections that otherwise work perfectly well.
1468        let echoed = serde_json::json!("2099-01-01");
1469        assert_eq!(negotiated_version(Some(&echoed)), "2099-01-01");
1470    }
1471
1472    #[test]
1473    fn negotiation_falls_back_when_the_server_omits_the_field() {
1474        let _guard = always_on_tracing_guard();
1475        assert_eq!(negotiated_version(None), PREFERRED_PROTOCOL_VERSION);
1476    }
1477
1478    #[test]
1479    fn negotiation_falls_back_when_the_echo_is_not_a_string() {
1480        let _guard = always_on_tracing_guard();
1481        let echoed = serde_json::json!(20251125);
1482        assert_eq!(
1483            negotiated_version(Some(&echoed)),
1484            PREFERRED_PROTOCOL_VERSION
1485        );
1486    }
1487
1488    #[tokio::test]
1489    async fn connect_offers_the_preferred_version_and_records_the_echo() {
1490        let _guard = always_on_tracing_guard();
1491        // Echoes back a *different* supported revision than the one offered,
1492        // which is exactly what a server one step behind does.
1493        let script = r#"
1494import sys, json
1495for line in sys.stdin:
1496    line = line.strip()
1497    if not line:
1498        continue
1499    req = json.loads(line)
1500    if req.get("method") == "initialize":
1501        offered = req["params"]["protocolVersion"]
1502        assert offered == "2025-11-25", offered
1503        sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": req.get("id"),
1504            "result": {"capabilities": {}, "protocolVersion": "2025-03-26"}}) + "\n")
1505        sys.stdout.flush()
1506"#;
1507        let mut client = spawn_stub_client(script).await;
1508        client.connect().await.expect("connect should succeed");
1509        assert_eq!(client.protocol_version(), Some("2025-03-26"));
1510    }
1511
1512    #[tokio::test]
1513    async fn protocol_version_is_none_before_connect() {
1514        let client = spawn_stub_client(STUB_INIT_LIST_CALL).await;
1515        assert!(client.protocol_version().is_none());
1516    }
1517
1518    // ─── construction over HTTP ───────────────────────────────────────────
1519
1520    #[test]
1521    fn connect_http_builds_a_client_without_touching_the_network() {
1522        // No server is listening; construction must still succeed, because
1523        // the first request is what connects.
1524        assert!(MCPClient::connect_http("http://127.0.0.1:1/mcp", &HashMap::new(), &[]).is_ok());
1525    }
1526
1527    struct NoopRefresher;
1528    #[async_trait::async_trait]
1529    impl crate::transport::BearerRefresher for NoopRefresher {
1530        async fn refresh(&self) -> anyhow::Result<String> {
1531            Ok("Bearer x".to_string())
1532        }
1533    }
1534
1535    #[tokio::test]
1536    async fn set_refresher_on_stdio_is_a_noop() {
1537        // stdio has no bearer, so the transport's default no-op handles it; this
1538        // drives MCPClient::set_refresher and the default trait method.
1539        let mut client = spawn_stub_client(STUB_INIT_LIST_CALL).await;
1540        client.set_refresher(std::sync::Arc::new(NoopRefresher));
1541    }
1542
1543    #[tokio::test]
1544    async fn set_refresher_on_http_is_accepted() {
1545        let mut client =
1546            MCPClient::connect_http("http://127.0.0.1:1/mcp", &HashMap::new(), &[]).unwrap();
1547        // Exercise the refresher itself so its body is covered.
1548        use crate::transport::BearerRefresher as _;
1549        assert_eq!(NoopRefresher.refresh().await.unwrap(), "Bearer x");
1550        client.set_refresher(std::sync::Arc::new(NoopRefresher));
1551    }
1552
1553    #[test]
1554    fn connect_http_rejects_an_unparseable_url() {
1555        assert!(MCPClient::connect_http("not a url", &HashMap::new(), &[]).is_err());
1556    }
1557
1558    #[tokio::test]
1559    async fn from_config_builds_the_stdio_transport() {
1560        let _guard = always_on_tracing_guard();
1561        let config = MCPServerConfig::stdio(
1562            "s",
1563            "python3",
1564            vec!["-c".into(), STUB_INIT_LIST_CALL.into()],
1565        );
1566        let mut client = MCPClient::from_config(&config)
1567            .await
1568            .expect("stdio config should connect");
1569        client.connect().await.expect("handshake should succeed");
1570        assert_eq!(client.list_tools().await.unwrap().len(), 1);
1571    }
1572
1573    #[tokio::test]
1574    async fn from_config_builds_the_http_transport() {
1575        let config = MCPServerConfig::http("s", "http://127.0.0.1:1/mcp");
1576        assert!(MCPClient::from_config(&config).await.is_ok());
1577    }
1578
1579    #[tokio::test]
1580    async fn from_config_with_auth_injects_a_bearer_for_http() {
1581        // The header-injection arm: construction succeeds without a network
1582        // round-trip (connecting happens later).
1583        let config = MCPServerConfig::http("s", "http://127.0.0.1:1/mcp");
1584        let header = Some(("Authorization".to_string(), "Bearer tok".to_string()));
1585        assert!(
1586            MCPClient::from_config_with_auth(&config, header, &[])
1587                .await
1588                .is_ok()
1589        );
1590    }
1591
1592    #[tokio::test]
1593    async fn from_config_rejects_an_unresolvable_entry() {
1594        let config = MCPServerConfig {
1595            name: "broken".to_string(),
1596            ..Default::default()
1597        };
1598        let err = MCPClient::from_config(&config)
1599            .await
1600            .err()
1601            .expect("an entry with neither command nor url cannot connect");
1602        assert!(err.to_string().contains("either a `command`"), "got: {err}");
1603    }
1604}