1use 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
16pub const MCP_PROTOCOL_VERSION: &str = PREFERRED_PROTOCOL_VERSION;
22
23const MAX_LIST_PAGES: usize = 10_000;
28
29fn 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
47pub struct McpClient<T: McpTransport> {
68 transport: Arc<T>,
69 server_name: String,
70 server_info: Option<InitializeResult>,
71 negotiated_version: Option<String>,
73}
74
75impl<T: McpTransport> McpClient<T> {
76 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 #[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 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(¶ms)?), 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 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 self.transport.set_protocol_version(&negotiated).await;
184 self.negotiated_version = Some(negotiated);
185
186 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 #[must_use]
196 pub fn server_name(&self) -> &str {
197 &self.server_name
198 }
199
200 #[must_use]
202 pub const fn server_info(&self) -> Option<&InitializeResult> {
203 self.server_info.as_ref()
204 }
205
206 #[must_use]
212 pub fn protocol_version(&self) -> Option<&str> {
213 self.negotiated_version.as_deref()
214 }
215
216 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 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 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(¶ms)?), 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 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 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 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(¶ms)?), 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 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 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(¶ms)?), 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 #[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 #[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 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 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 #[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}