Skip to main content

agent_sdk/mcp/
client.rs

1//! MCP client implementation.
2
3use anyhow::{Context, Result, bail};
4use serde_json::{Value, json};
5use std::sync::Arc;
6
7use super::protocol::JsonRpcRequest;
8use super::protocol::{
9    ClientCapabilities, ClientInfo, InitializeParams, InitializeResult, McpPrompt, McpResource,
10    McpToolCallResult, McpToolDefinition, PREFERRED_PROTOCOL_VERSION, PromptGetParams,
11    PromptGetResult, PromptsListResult, ResourceReadParams, ResourceReadResult,
12    ResourcesListResult, ToolCallParams, ToolsListResult, is_known_protocol_version,
13};
14use super::transport::McpTransport;
15
16/// MCP protocol revision this client advertises during `initialize`.
17///
18/// Retained as a public alias of [`PREFERRED_PROTOCOL_VERSION`] for backwards
19/// compatibility. The revision actually used for a connection is whatever the
20/// server selects during the handshake — see [`McpClient::protocol_version`].
21pub const MCP_PROTOCOL_VERSION: &str = PREFERRED_PROTOCOL_VERSION;
22
23/// Upper bound on pages followed by a cursor-paginated list call.
24///
25/// Bounds the loop in case a misbehaving server returns a non-empty cursor
26/// forever; legitimate servers terminate well before this.
27const MAX_LIST_PAGES: usize = 10_000;
28
29/// Decide whether to continue paginating.
30///
31/// Returns `Ok(Some(cursor))` to fetch another page, `Ok(None)` to stop (no
32/// further cursor), and increments/guards the page counter. Errors if the
33/// server exceeds [`MAX_LIST_PAGES`] (likely a cursor that never terminates).
34fn next_cursor_or_stop(next: Option<String>, pages: &mut usize) -> Result<Option<String>> {
35    *pages += 1;
36    match next {
37        Some(cursor) if !cursor.is_empty() => {
38            if *pages >= MAX_LIST_PAGES {
39                bail!("MCP list pagination exceeded {MAX_LIST_PAGES} pages; aborting");
40            }
41            Ok(Some(cursor))
42        }
43        _ => Ok(None),
44    }
45}
46
47/// MCP client for communicating with MCP servers.
48///
49/// The client handles the MCP protocol, including initialization,
50/// tool discovery, and tool execution.
51///
52/// # Example
53///
54/// ```ignore
55/// use agent_sdk::mcp::{McpClient, StdioTransport};
56///
57/// // Spawn server and create client
58/// let transport = StdioTransport::spawn("npx", &["-y", "mcp-server"]).await?;
59/// let client = McpClient::new(transport, "my-server".to_string()).await?;
60///
61/// // List available tools
62/// let tools = client.list_tools().await?;
63///
64/// // Call a tool
65/// let result = client.call_tool("tool_name", json!({"arg": "value"})).await?;
66/// ```
67pub struct McpClient<T: McpTransport> {
68    transport: Arc<T>,
69    server_name: String,
70    server_info: Option<InitializeResult>,
71    /// Protocol revision selected by the server during `initialize`.
72    negotiated_version: Option<String>,
73}
74
75impl<T: McpTransport> McpClient<T> {
76    /// Create a new MCP client and initialize the connection.
77    ///
78    /// # Arguments
79    ///
80    /// * `transport` - The transport to use for communication
81    /// * `server_name` - A name to identify this server connection
82    ///
83    /// # Errors
84    ///
85    /// Returns an error if initialization fails.
86    pub async fn new(transport: Arc<T>, server_name: String) -> Result<Self> {
87        let mut client = Self {
88            transport,
89            server_name,
90            server_info: None,
91            negotiated_version: None,
92        };
93
94        client.initialize().await?;
95
96        Ok(client)
97    }
98
99    /// Create a client without initialization.
100    ///
101    /// Use this if you need to control when initialization happens.
102    #[must_use]
103    pub const fn new_uninitialized(transport: Arc<T>, server_name: String) -> Self {
104        Self {
105            transport,
106            server_name,
107            server_info: None,
108            negotiated_version: None,
109        }
110    }
111
112    /// Initialize the MCP connection.
113    ///
114    /// This must be called before using other methods.
115    ///
116    /// # Errors
117    ///
118    /// Returns an error if the server rejects initialization.
119    pub async fn initialize(&mut self) -> Result<&InitializeResult> {
120        #[cfg(feature = "otel")]
121        let started_at = std::time::Instant::now();
122        #[cfg(feature = "otel")]
123        let mut span = {
124            use crate::observability::langfuse;
125            let mut span = start_mcp_span("mcp.initialize", &self.server_name);
126            langfuse::tag_observation(&mut span, langfuse::ObservationType::Chain);
127            span
128        };
129
130        let result = self.initialize_inner().await;
131
132        #[cfg(feature = "otel")]
133        finish_mcp_span(
134            &mut span,
135            &result,
136            "initialize",
137            &self.server_name,
138            started_at,
139        );
140
141        result?;
142
143        self.server_info
144            .as_ref()
145            .context("Server info not available")
146    }
147
148    async fn initialize_inner(&mut self) -> Result<()> {
149        let params = InitializeParams {
150            protocol_version: PREFERRED_PROTOCOL_VERSION.to_string(),
151            capabilities: ClientCapabilities::default(),
152            client_info: ClientInfo {
153                name: "agent-sdk".to_string(),
154                version: env!("CARGO_PKG_VERSION").to_string(),
155            },
156        };
157
158        let request = JsonRpcRequest::new("initialize", Some(serde_json::to_value(&params)?), 0);
159
160        let response = self.transport.send(request).await?;
161
162        let result: InitializeResult = response
163            .result
164            .map(serde_json::from_value)
165            .transpose()
166            .context("Failed to parse initialize response")?
167            .context("Initialize response missing result")?;
168
169        // Honour the revision the server actually selected. The server may
170        // downgrade to an older revision (e.g. a legacy `2024-11-05` server);
171        // we adapt to its choice rather than insisting on our preference. An
172        // unrecognised revision is not fatal — proceed but log it.
173        let negotiated = result.protocol_version.clone();
174        if !is_known_protocol_version(&negotiated) {
175            log::warn!(
176                "MCP server '{}' negotiated unknown protocol revision '{}' (advertised '{}')",
177                self.server_name,
178                negotiated,
179                PREFERRED_PROTOCOL_VERSION,
180            );
181        }
182        // Inform the transport so out-of-band carriers (HTTP header) can use it.
183        self.transport.set_protocol_version(&negotiated).await;
184        self.negotiated_version = Some(negotiated);
185
186        // Send initialized notification (fire-and-forget)
187        let notification = JsonRpcRequest::new("notifications/initialized", None, 0);
188        let _ = self.transport.send_notification(notification).await;
189
190        self.server_info = Some(result);
191        Ok(())
192    }
193
194    /// Get the server name.
195    #[must_use]
196    pub fn server_name(&self) -> &str {
197        &self.server_name
198    }
199
200    /// Get server info if initialized.
201    #[must_use]
202    pub const fn server_info(&self) -> Option<&InitializeResult> {
203        self.server_info.as_ref()
204    }
205
206    /// The MCP protocol revision negotiated with the server.
207    ///
208    /// Returns `None` until [`McpClient::initialize`] has completed. This is
209    /// the revision the *server* selected, which may be older than
210    /// [`PREFERRED_PROTOCOL_VERSION`] if the server is on a legacy build.
211    #[must_use]
212    pub fn protocol_version(&self) -> Option<&str> {
213        self.negotiated_version.as_deref()
214    }
215
216    /// List available tools from the server.
217    ///
218    /// # Errors
219    ///
220    /// Returns an error if the request fails.
221    pub async fn list_tools(&self) -> Result<Vec<McpToolDefinition>> {
222        #[cfg(feature = "otel")]
223        let started_at = std::time::Instant::now();
224        #[cfg(feature = "otel")]
225        let mut span = {
226            use crate::observability::langfuse;
227            let mut span = start_mcp_span("mcp.tools/list", &self.server_name);
228            langfuse::tag_observation(&mut span, langfuse::ObservationType::Chain);
229            span
230        };
231
232        let result = self.list_tools_inner().await;
233
234        #[cfg(feature = "otel")]
235        {
236            use opentelemetry::KeyValue;
237            use opentelemetry::trace::Span;
238            if let Ok(ref tools) = result {
239                span.set_attribute(KeyValue::new(
240                    "mcp.tools.count",
241                    i64::try_from(tools.len()).unwrap_or(0),
242                ));
243            }
244            finish_mcp_span(
245                &mut span,
246                &result,
247                "tools/list",
248                &self.server_name,
249                started_at,
250            );
251        }
252
253        result
254    }
255
256    async fn list_tools_inner(&self) -> Result<Vec<McpToolDefinition>> {
257        let mut all = Vec::new();
258        let mut cursor: Option<String> = None;
259        let mut pages = 0usize;
260
261        loop {
262            let params = cursor.as_ref().map(|c| json!({ "cursor": c }));
263            let request = JsonRpcRequest::new("tools/list", params, 0);
264            let response = self.transport.send(request).await?;
265
266            let value = response
267                .result
268                .context("tools/list response missing result")?;
269            // `ToolsListResult` does not model `nextCursor`, so read it from the
270            // raw value before deserializing the typed page.
271            let next = value
272                .get("nextCursor")
273                .and_then(serde_json::Value::as_str)
274                .map(ToString::to_string);
275            let result: ToolsListResult =
276                serde_json::from_value(value).context("Failed to parse tools/list response")?;
277            all.extend(result.tools);
278
279            match next_cursor_or_stop(next, &mut pages)? {
280                Some(c) => cursor = Some(c),
281                None => break,
282            }
283        }
284
285        Ok(all)
286    }
287
288    /// Call a tool on the server.
289    ///
290    /// # Arguments
291    ///
292    /// * `name` - Tool name to call
293    /// * `arguments` - Tool arguments as JSON
294    ///
295    /// # Errors
296    ///
297    /// Returns an error if the tool call fails.
298    pub async fn call_tool(&self, name: &str, arguments: Value) -> Result<McpToolCallResult> {
299        #[cfg(feature = "otel")]
300        let started_at = std::time::Instant::now();
301        #[cfg(feature = "otel")]
302        let mut span = {
303            use crate::observability::langfuse;
304            use opentelemetry::KeyValue;
305            let mut span = start_mcp_span_with_attrs(
306                "mcp.tools/call",
307                vec![
308                    KeyValue::new("mcp.server.name", self.server_name.clone()),
309                    KeyValue::new("gen_ai.tool.name", name.to_string()),
310                ],
311            );
312            langfuse::tag_observation(&mut span, langfuse::ObservationType::Tool);
313            span
314        };
315
316        let result = self.call_tool_inner(name, arguments).await;
317
318        #[cfg(feature = "otel")]
319        finish_mcp_call_tool_span(
320            &mut span,
321            &result,
322            "tools/call",
323            &self.server_name,
324            started_at,
325        );
326
327        result
328    }
329
330    async fn call_tool_inner(&self, name: &str, arguments: Value) -> Result<McpToolCallResult> {
331        let params = ToolCallParams {
332            name: name.to_string(),
333            arguments: Some(arguments),
334        };
335
336        let request = JsonRpcRequest::new("tools/call", Some(serde_json::to_value(&params)?), 0);
337
338        let response = self.transport.send(request).await?;
339
340        if let Some(ref error) = response.error {
341            bail!("Tool call failed: {} (code {})", error.message, error.code);
342        }
343
344        let result: McpToolCallResult = response
345            .result
346            .map(serde_json::from_value)
347            .transpose()
348            .context("Failed to parse tools/call response")?
349            .context("tools/call response missing result")?;
350
351        Ok(result)
352    }
353
354    /// Call a tool with raw Value arguments.
355    ///
356    /// # Arguments
357    ///
358    /// * `name` - Tool name to call
359    /// * `arguments` - Tool arguments as optional JSON
360    ///
361    /// # Errors
362    ///
363    /// Returns an error if the tool call fails.
364    pub async fn call_tool_raw(
365        &self,
366        name: &str,
367        arguments: Option<Value>,
368    ) -> Result<McpToolCallResult> {
369        let args = arguments.unwrap_or_else(|| json!({}));
370        self.call_tool(name, args).await
371    }
372
373    /// List resources exposed by the server (`resources/list`).
374    ///
375    /// Resources are addressable data (files, database rows, API payloads) the
376    /// server makes available for reading. Returns an empty list if the server
377    /// did not advertise the `resources` capability.
378    ///
379    /// # Errors
380    ///
381    /// Returns an error if the request fails or the response cannot be parsed.
382    pub async fn list_resources(&self) -> Result<Vec<McpResource>> {
383        if !self.supports_resources() {
384            return Ok(Vec::new());
385        }
386        #[cfg(feature = "otel")]
387        let started_at = std::time::Instant::now();
388        #[cfg(feature = "otel")]
389        let mut span = {
390            use crate::observability::langfuse;
391            let mut span = start_mcp_span("mcp.resources/list", &self.server_name);
392            langfuse::tag_observation(&mut span, langfuse::ObservationType::Chain);
393            span
394        };
395
396        let result = self.list_resources_inner().await;
397
398        #[cfg(feature = "otel")]
399        finish_mcp_span(
400            &mut span,
401            &result,
402            "resources/list",
403            &self.server_name,
404            started_at,
405        );
406
407        result
408    }
409
410    async fn list_resources_inner(&self) -> Result<Vec<McpResource>> {
411        let mut all = Vec::new();
412        let mut cursor: Option<String> = None;
413        let mut pages = 0usize;
414
415        loop {
416            let params = cursor.as_ref().map(|c| json!({ "cursor": c }));
417            let request = JsonRpcRequest::new("resources/list", params, 0);
418            let response = self.transport.send(request).await?;
419            let result: ResourcesListResult = response
420                .result
421                .map(serde_json::from_value)
422                .transpose()
423                .context("Failed to parse resources/list response")?
424                .context("resources/list response missing result")?;
425            all.extend(result.resources);
426
427            match next_cursor_or_stop(result.next_cursor, &mut pages)? {
428                Some(c) => cursor = Some(c),
429                None => break,
430            }
431        }
432
433        Ok(all)
434    }
435
436    /// Read a resource by URI (`resources/read`).
437    ///
438    /// # Errors
439    ///
440    /// Returns an error if the request fails or the response cannot be parsed.
441    pub async fn read_resource(&self, uri: &str) -> Result<ResourceReadResult> {
442        #[cfg(feature = "otel")]
443        let started_at = std::time::Instant::now();
444        #[cfg(feature = "otel")]
445        let mut span = {
446            use crate::observability::langfuse;
447            let mut span = start_mcp_span("mcp.resources/read", &self.server_name);
448            langfuse::tag_observation(&mut span, langfuse::ObservationType::Chain);
449            span
450        };
451
452        let result = self.read_resource_inner(uri).await;
453
454        #[cfg(feature = "otel")]
455        finish_mcp_span(
456            &mut span,
457            &result,
458            "resources/read",
459            &self.server_name,
460            started_at,
461        );
462
463        result
464    }
465
466    async fn read_resource_inner(&self, uri: &str) -> Result<ResourceReadResult> {
467        let params = ResourceReadParams {
468            uri: uri.to_string(),
469        };
470        let request =
471            JsonRpcRequest::new("resources/read", Some(serde_json::to_value(&params)?), 0);
472        let response = self.transport.send(request).await?;
473        let result: ResourceReadResult = response
474            .result
475            .map(serde_json::from_value)
476            .transpose()
477            .context("Failed to parse resources/read response")?
478            .context("resources/read response missing result")?;
479        Ok(result)
480    }
481
482    /// List prompts exposed by the server (`prompts/list`).
483    ///
484    /// Returns an empty list if the server did not advertise the `prompts`
485    /// capability.
486    ///
487    /// # Errors
488    ///
489    /// Returns an error if the request fails or the response cannot be parsed.
490    pub async fn list_prompts(&self) -> Result<Vec<McpPrompt>> {
491        if !self.supports_prompts() {
492            return Ok(Vec::new());
493        }
494        #[cfg(feature = "otel")]
495        let started_at = std::time::Instant::now();
496        #[cfg(feature = "otel")]
497        let mut span = {
498            use crate::observability::langfuse;
499            let mut span = start_mcp_span("mcp.prompts/list", &self.server_name);
500            langfuse::tag_observation(&mut span, langfuse::ObservationType::Chain);
501            span
502        };
503
504        let result = self.list_prompts_inner().await;
505
506        #[cfg(feature = "otel")]
507        finish_mcp_span(
508            &mut span,
509            &result,
510            "prompts/list",
511            &self.server_name,
512            started_at,
513        );
514
515        result
516    }
517
518    async fn list_prompts_inner(&self) -> Result<Vec<McpPrompt>> {
519        let mut all = Vec::new();
520        let mut cursor: Option<String> = None;
521        let mut pages = 0usize;
522
523        loop {
524            let params = cursor.as_ref().map(|c| json!({ "cursor": c }));
525            let request = JsonRpcRequest::new("prompts/list", params, 0);
526            let response = self.transport.send(request).await?;
527            let result: PromptsListResult = response
528                .result
529                .map(serde_json::from_value)
530                .transpose()
531                .context("Failed to parse prompts/list response")?
532                .context("prompts/list response missing result")?;
533            all.extend(result.prompts);
534
535            match next_cursor_or_stop(result.next_cursor, &mut pages)? {
536                Some(c) => cursor = Some(c),
537                None => break,
538            }
539        }
540
541        Ok(all)
542    }
543
544    /// Fetch and render a prompt by name (`prompts/get`).
545    ///
546    /// # Arguments
547    ///
548    /// * `name` - Prompt name to fetch.
549    /// * `arguments` - Optional arguments to interpolate into the template.
550    ///
551    /// # Errors
552    ///
553    /// Returns an error if the request fails or the response cannot be parsed.
554    pub async fn get_prompt(
555        &self,
556        name: &str,
557        arguments: Option<Value>,
558    ) -> Result<PromptGetResult> {
559        #[cfg(feature = "otel")]
560        let started_at = std::time::Instant::now();
561        #[cfg(feature = "otel")]
562        let mut span = {
563            use crate::observability::langfuse;
564            let mut span = start_mcp_span("mcp.prompts/get", &self.server_name);
565            langfuse::tag_observation(&mut span, langfuse::ObservationType::Chain);
566            span
567        };
568
569        let result = self.get_prompt_inner(name, arguments).await;
570
571        #[cfg(feature = "otel")]
572        finish_mcp_span(
573            &mut span,
574            &result,
575            "prompts/get",
576            &self.server_name,
577            started_at,
578        );
579
580        result
581    }
582
583    async fn get_prompt_inner(
584        &self,
585        name: &str,
586        arguments: Option<Value>,
587    ) -> Result<PromptGetResult> {
588        let params = PromptGetParams {
589            name: name.to_string(),
590            arguments,
591        };
592        let request = JsonRpcRequest::new("prompts/get", Some(serde_json::to_value(&params)?), 0);
593        let response = self.transport.send(request).await?;
594        let result: PromptGetResult = response
595            .result
596            .map(serde_json::from_value)
597            .transpose()
598            .context("Failed to parse prompts/get response")?
599            .context("prompts/get response missing result")?;
600        Ok(result)
601    }
602
603    /// Whether the server advertised the `resources` capability.
604    #[must_use]
605    pub fn supports_resources(&self) -> bool {
606        self.server_info
607            .as_ref()
608            .is_some_and(|info| info.capabilities.resources.is_some())
609    }
610
611    /// Whether the server advertised the `prompts` capability.
612    #[must_use]
613    pub fn supports_prompts(&self) -> bool {
614        self.server_info
615            .as_ref()
616            .is_some_and(|info| info.capabilities.prompts.is_some())
617    }
618
619    /// Close the client connection.
620    ///
621    /// # Errors
622    ///
623    /// Returns an error if the transport fails to close.
624    pub async fn close(&self) -> Result<()> {
625        self.transport.close().await
626    }
627}
628
629#[cfg(feature = "otel")]
630fn start_mcp_span(
631    name: impl Into<std::borrow::Cow<'static, str>>,
632    server_name: &str,
633) -> opentelemetry::global::BoxedSpan {
634    use opentelemetry::KeyValue;
635    start_mcp_span_with_attrs(
636        name,
637        vec![KeyValue::new("mcp.server.name", server_name.to_string())],
638    )
639}
640
641#[cfg(feature = "otel")]
642fn start_mcp_span_with_attrs(
643    name: impl Into<std::borrow::Cow<'static, str>>,
644    attrs: Vec<opentelemetry::KeyValue>,
645) -> opentelemetry::global::BoxedSpan {
646    use crate::observability::{baggage, spans};
647    let mut span = spans::start_client_span(name, attrs);
648    baggage::copy_baggage_to_active_span(&mut span);
649    span
650}
651
652#[cfg(feature = "otel")]
653fn finish_mcp_span<T>(
654    span: &mut opentelemetry::global::BoxedSpan,
655    result: &Result<T>,
656    method: &'static str,
657    server_name: &str,
658    started_at: std::time::Instant,
659) {
660    use crate::observability::{metrics, spans};
661    use opentelemetry::KeyValue;
662    use opentelemetry::trace::Span;
663
664    let mut metric_attrs = vec![
665        KeyValue::new("mcp.method", method),
666        KeyValue::new("mcp.server.name", server_name.to_string()),
667    ];
668    if let Err(err) = result {
669        spans::set_span_error(span, "mcp_error", &format!("{err}"));
670        metric_attrs.push(KeyValue::new(
671            crate::observability::attrs::ERROR_TYPE,
672            "mcp_error",
673        ));
674    }
675    let elapsed_secs = started_at.elapsed().as_secs_f64();
676    metrics::Metrics::global()
677        .mcp_requests_duration
678        .record(elapsed_secs, &metric_attrs);
679    span.end();
680}
681
682#[cfg(feature = "otel")]
683fn finish_mcp_call_tool_span(
684    span: &mut opentelemetry::global::BoxedSpan,
685    result: &Result<super::protocol::McpToolCallResult>,
686    method: &'static str,
687    server_name: &str,
688    started_at: std::time::Instant,
689) {
690    use crate::observability::{metrics, spans};
691    use opentelemetry::KeyValue;
692    use opentelemetry::trace::Span;
693
694    let mut metric_attrs = vec![
695        KeyValue::new("mcp.method", method),
696        KeyValue::new("mcp.server.name", server_name.to_string()),
697    ];
698    let error_kind: Option<&'static str> = match result {
699        Ok(tool_result) if tool_result.is_error => {
700            let error_text = tool_result
701                .content
702                .iter()
703                .find_map(|c| match c {
704                    super::protocol::McpContent::Text { text } => Some(text.as_str()),
705                    _ => None,
706                })
707                .unwrap_or("MCP tool returned error");
708            spans::set_span_error(span, "tool_error", error_text);
709            Some("tool_error")
710        }
711        Err(err) => {
712            spans::set_span_error(span, "mcp_error", &format!("{err}"));
713            Some("mcp_error")
714        }
715        Ok(_) => None,
716    };
717    if let Some(kind) = error_kind {
718        metric_attrs.push(KeyValue::new(crate::observability::attrs::ERROR_TYPE, kind));
719    }
720    let elapsed_secs = started_at.elapsed().as_secs_f64();
721    metrics::Metrics::global()
722        .mcp_requests_duration
723        .record(elapsed_secs, &metric_attrs);
724    span.end();
725}
726
727#[cfg(test)]
728mod tests {
729    use super::*;
730    use crate::mcp::protocol::JsonRpcResponse;
731    use async_trait::async_trait;
732
733    /// Transport that serves a two-page `tools/list`, gated by the `cursor`
734    /// param, so pagination can be exercised with no live server.
735    struct PagingTransport;
736
737    #[async_trait]
738    impl McpTransport for PagingTransport {
739        async fn send(&self, request: JsonRpcRequest) -> Result<JsonRpcResponse> {
740            let cursor = request
741                .params
742                .as_ref()
743                .and_then(|p| p.get("cursor"))
744                .and_then(serde_json::Value::as_str)
745                .map(ToString::to_string);
746
747            let result = match request.method.as_str() {
748                "tools/list" => match cursor.as_deref() {
749                    None => json!({
750                        "tools": [{"name": "alpha", "inputSchema": {"type": "object"}}],
751                        "nextCursor": "page2",
752                    }),
753                    Some("page2") => json!({
754                        "tools": [{"name": "beta", "inputSchema": {"type": "object"}}],
755                    }),
756                    Some(other) => bail!("unexpected cursor {other}"),
757                },
758                other => bail!("unexpected method {other}"),
759            };
760
761            Ok(JsonRpcResponse {
762                jsonrpc: "2.0".to_string(),
763                result: Some(result),
764                error: None,
765                id: request.id,
766            })
767        }
768
769        async fn send_notification(&self, _request: JsonRpcRequest) -> Result<()> {
770            Ok(())
771        }
772
773        async fn close(&self) -> Result<()> {
774            Ok(())
775        }
776    }
777
778    /// Regression test for finding 6: `list_tools` must follow `nextCursor` and
779    /// merge every page, not silently return only page 1.
780    #[tokio::test]
781    async fn list_tools_follows_pagination() -> Result<()> {
782        let client = McpClient::new_uninitialized(Arc::new(PagingTransport), "test".to_string());
783        let tools = client.list_tools().await?;
784        let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
785        assert_eq!(
786            names,
787            vec!["alpha", "beta"],
788            "both pages must be merged in order"
789        );
790        Ok(())
791    }
792
793    #[test]
794    fn test_mcp_protocol_version() {
795        assert!(!MCP_PROTOCOL_VERSION.is_empty());
796    }
797
798    #[test]
799    fn test_client_info() {
800        let info = ClientInfo {
801            name: "test".to_string(),
802            version: "1.0.0".to_string(),
803        };
804
805        let json = serde_json::to_string(&info).expect("serialize");
806        assert!(json.contains("test"));
807        assert!(json.contains("1.0.0"));
808    }
809}