1use std::collections::HashMap;
4use std::future::Future;
5use std::pin::Pin;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::sync::{Arc, RwLock};
8use std::time::{Duration, Instant};
9
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
13
14use crate::error::Error;
15use crate::llm::types::ToolDefinition;
16use crate::tool::{Tool, ToolOutput};
17
18const PROTOCOL_VERSION: &str = "2025-11-25";
19
20static REQUEST_TIMEOUT: std::sync::LazyLock<Duration> = std::sync::LazyLock::new(|| {
25 mcp_timeout_from(std::env::var("HEARTBIT_MCP_TIMEOUT_SECS").ok().as_deref())
26});
27
28fn mcp_timeout_from(val: Option<&str>) -> Duration {
29 let secs = val
30 .and_then(|v| v.parse::<u64>().ok())
31 .map_or(30, |s| s.clamp(5, 600));
32 Duration::from_secs(secs)
33}
34
35#[derive(Debug, Serialize)]
38struct JsonRpcRequest {
39 jsonrpc: &'static str,
40 method: String,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 params: Option<Value>,
43 id: u64,
44}
45
46#[derive(Debug, Serialize)]
47struct JsonRpcNotification {
48 jsonrpc: &'static str,
49 method: String,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 params: Option<Value>,
52}
53
54#[derive(Debug, Deserialize)]
55struct JsonRpcResponse {
56 result: Option<Value>,
57 error: Option<JsonRpcError>,
58}
59
60#[derive(Debug, Deserialize)]
61struct JsonRpcError {
62 code: i64,
63 message: String,
64}
65
66#[derive(Debug, Deserialize)]
69#[serde(rename_all = "camelCase")]
70struct McpToolDef {
71 name: String,
72 #[serde(default)]
73 description: Option<String>,
74 #[serde(default)]
75 input_schema: Option<Value>,
76}
77
78#[derive(Debug, Deserialize)]
79struct McpToolsListResult {
80 tools: Vec<McpToolDef>,
81 #[serde(default, rename = "nextCursor")]
82 next_cursor: Option<String>,
83}
84
85#[derive(Debug, Deserialize)]
86struct McpContent {
87 #[serde(rename = "type")]
88 content_type: String,
89 #[serde(default)]
90 text: Option<String>,
91}
92
93#[derive(Debug, Deserialize)]
94#[serde(rename_all = "camelCase")]
95struct McpCallToolResult {
96 content: Vec<McpContent>,
97 #[serde(default)]
98 is_error: bool,
99}
100
101#[derive(Debug, Default, Deserialize)]
104#[allow(dead_code)]
105struct ServerCapabilities {
106 #[serde(default)]
107 resources: Option<ResourcesCapability>,
108 #[serde(default)]
109 prompts: Option<PromptsCapability>,
110 #[serde(default)]
111 logging: Option<Value>,
112}
113
114#[derive(Debug, Default, Deserialize)]
115#[serde(rename_all = "camelCase")]
116#[allow(dead_code)]
117struct ResourcesCapability {
118 #[serde(default)]
119 subscribe: bool,
120 #[serde(default)]
121 list_changed: bool,
122}
123
124#[derive(Debug, Default, Deserialize)]
125#[serde(rename_all = "camelCase")]
126#[allow(dead_code)]
127struct PromptsCapability {
128 #[serde(default)]
129 list_changed: bool,
130}
131
132#[derive(Debug, Default, Deserialize)]
133#[serde(rename_all = "camelCase")]
134#[allow(dead_code)]
135struct InitializeResult {
136 #[serde(default)]
137 capabilities: ServerCapabilities,
138 #[serde(default)]
139 server_info: Option<Value>,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
146#[serde(rename_all = "camelCase")]
147pub struct McpResourceDef {
148 pub uri: String,
150 pub name: String,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub description: Option<String>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub mime_type: Option<String>,
158}
159
160#[derive(Debug, Deserialize)]
161struct McpResourcesListResult {
162 resources: Vec<McpResourceDef>,
163 #[serde(default, rename = "nextCursor")]
164 next_cursor: Option<String>,
165}
166
167#[derive(Debug, Clone, Deserialize)]
169#[serde(rename_all = "camelCase")]
170pub struct McpResourceContent {
171 pub uri: String,
173 #[serde(default)]
175 pub mime_type: Option<String>,
176 #[serde(default)]
178 pub text: Option<String>,
179 #[serde(default)]
181 pub blob: Option<String>,
182}
183
184#[derive(Debug, Deserialize)]
185struct McpResourceReadResult {
186 contents: Vec<McpResourceContent>,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct McpPromptDef {
194 pub name: String,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub description: Option<String>,
199 #[serde(default, skip_serializing_if = "Vec::is_empty")]
201 pub arguments: Vec<McpPromptArgument>,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct McpPromptArgument {
207 pub name: String,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub description: Option<String>,
212 #[serde(default)]
214 pub required: bool,
215}
216
217#[derive(Debug, Deserialize)]
218struct McpPromptsListResult {
219 prompts: Vec<McpPromptDef>,
220 #[serde(default, rename = "nextCursor")]
221 next_cursor: Option<String>,
222}
223
224#[derive(Debug, Clone, Deserialize)]
226pub struct McpPromptMessage {
227 pub role: String,
229 pub content: McpPromptMessageContent,
231}
232
233#[derive(Debug, Clone, Deserialize)]
235#[serde(rename_all = "camelCase")]
236pub struct McpPromptMessageContent {
237 #[serde(rename = "type")]
239 pub content_type: String,
240 #[serde(default)]
242 pub text: Option<String>,
243}
244
245#[derive(Debug, Deserialize)]
246#[allow(dead_code)]
247struct McpPromptGetResult {
248 #[serde(default)]
249 description: Option<String>,
250 messages: Vec<McpPromptMessage>,
251}
252
253fn handle_log_notification(value: &Value) {
265 fn sanitize_log_field(s: &str) -> String {
268 const MAX: usize = 4 * 1024;
269 let mut out = String::with_capacity(s.len().min(MAX));
270 for c in s.chars() {
271 if out.len() >= MAX {
272 out.push_str("…[truncated]");
273 break;
274 }
275 if c.is_control() {
276 out.push(' ');
277 } else {
278 out.push(c);
279 }
280 }
281 out
282 }
283 if let Some(params) = value.get("params") {
284 let level = params
285 .get("level")
286 .and_then(|v| v.as_str())
287 .unwrap_or("info");
288 let logger_raw = params
289 .get("logger")
290 .and_then(|v| v.as_str())
291 .unwrap_or("mcp");
292 let data_raw = params.get("data").and_then(|v| v.as_str()).unwrap_or("");
293 let logger = sanitize_log_field(logger_raw);
294 let data = sanitize_log_field(data_raw);
295 match level {
296 "error" | "critical" | "alert" | "emergency" => {
297 tracing::error!(target: "mcp_server", logger = %logger, "{data}");
298 }
299 "warning" => {
300 tracing::warn!(target: "mcp_server", logger = %logger, "{data}");
301 }
302 "debug" => {
303 tracing::debug!(target: "mcp_server", logger = %logger, "{data}");
304 }
305 _ => {
306 tracing::info!(target: "mcp_server", logger = %logger, "{data}");
307 }
308 }
309 }
310}
311
312fn extract_sse_events(body: &str) -> Result<Vec<String>, Error> {
320 let mut events: Vec<String> = Vec::new();
321 let mut current_lines: Vec<&str> = Vec::new();
322
323 for line in body.lines() {
324 if line.trim().is_empty() {
325 if !current_lines.is_empty() {
327 events.push(current_lines.join("\n"));
328 current_lines.clear();
329 }
330 } else if let Some(rest) = line.strip_prefix("data:") {
331 let data = rest.strip_prefix(' ').unwrap_or(rest);
333 current_lines.push(data);
334 }
335 }
336
337 if !current_lines.is_empty() {
339 events.push(current_lines.join("\n"));
340 }
341
342 if events.is_empty() {
343 return Err(Error::Mcp("No data field in SSE response".into()));
344 }
345 Ok(events)
346}
347
348fn find_rpc_response(events: &[String], expected_id: u64) -> Result<String, Error> {
359 let mut null_id_error: Option<String> = None;
360 for event in events {
361 if let Ok(value) = serde_json::from_str::<Value>(event) {
362 if value.get("method").and_then(|m| m.as_str()) == Some("notifications/message") {
364 handle_log_notification(&value);
365 continue;
366 }
367 if value.get("id").and_then(|v| v.as_u64()) == Some(expected_id) {
368 return Ok(event.clone());
369 }
370 if value.get("id").map(|v| v.is_null()).unwrap_or(false)
373 && value.get("error").is_some()
374 && null_id_error.is_none()
375 {
376 null_id_error = Some(event.clone());
377 }
378 }
379 }
380 if let Some(ev) = null_id_error {
381 return Ok(ev);
382 }
383 Err(Error::Mcp(format!(
384 "No JSON-RPC response with id={expected_id} found in SSE stream (F-MCP-5)"
385 )))
386}
387
388fn mcp_result_to_tool_output(result: McpCallToolResult) -> ToolOutput {
389 let non_text_count = result
390 .content
391 .iter()
392 .filter(|c| c.content_type != "text")
393 .count();
394 let text: String = result
395 .content
396 .iter()
397 .filter_map(|c| {
398 if c.content_type == "text" {
399 c.text.as_deref()
400 } else {
401 None
402 }
403 })
404 .collect::<Vec<_>>()
405 .join("\n");
406
407 let output = if text.is_empty() && non_text_count > 0 {
408 format!(
409 "[MCP server returned {non_text_count} non-text content block(s) that cannot be displayed]"
410 )
411 } else {
412 text
413 };
414
415 if result.is_error {
416 ToolOutput::error(output)
417 } else {
418 ToolOutput::success(output)
419 }
420}
421
422const MCP_DESCRIPTION_MAX_BYTES: usize = 4 * 1024;
429
430fn mcp_tool_to_definition(tool: &McpToolDef) -> ToolDefinition {
431 let raw_desc = tool.description.clone().unwrap_or_default();
432 ToolDefinition {
433 name: tool.name.clone(),
434 description: sanitize_description(&raw_desc),
440 input_schema: tool
441 .input_schema
442 .clone()
443 .unwrap_or_else(|| serde_json::json!({"type": "object"})),
444 }
445}
446
447fn redact_idp_body(body: &str) -> String {
454 static REDACTORS: std::sync::LazyLock<[(regex::Regex, &'static str); 3]> =
462 std::sync::LazyLock::new(|| {
463 [
464 (
465 regex::Regex::new(r"eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+")
466 .expect("static jwt pattern"),
467 "[redacted-jwt]",
468 ),
469 (
470 regex::Regex::new(r"(?i)bearer\s+[A-Za-z0-9_\-\.=]+")
471 .expect("static bearer pattern"),
472 "[redacted-bearer]",
473 ),
474 (
475 regex::Regex::new(
476 r#"(?i)("(?:access|id|refresh|subject)_token"\s*:\s*")[^"]+"#,
477 )
478 .expect("static token-field pattern"),
479 "$1[redacted]",
480 ),
481 ]
482 });
483 let mut out = std::borrow::Cow::Borrowed(body);
484 for (re, repl) in REDACTORS.iter() {
485 match re.replace_all(&out, *repl) {
486 std::borrow::Cow::Borrowed(_) => {}
487 std::borrow::Cow::Owned(s) => out = std::borrow::Cow::Owned(s),
488 }
489 }
490 out.into_owned()
491}
492
493fn sanitize_description(s: &str) -> String {
497 let mut out = String::with_capacity(s.len().min(MCP_DESCRIPTION_MAX_BYTES));
498 for c in s.chars() {
499 if out.len() >= MCP_DESCRIPTION_MAX_BYTES {
500 out.push_str("…[truncated]");
501 break;
502 }
503 if c.is_control() {
505 out.push(' ');
506 } else {
507 out.push(c);
508 }
509 }
510 out
511}
512
513fn process_rpc_response(json_str: &str) -> Result<Value, Error> {
517 let rpc_response: JsonRpcResponse = serde_json::from_str(json_str)?;
518
519 if let Some(err) = rpc_response.error {
520 const MCP_ERROR_MESSAGE_MAX_BYTES: usize = 1024;
528 let truncated = if err.message.len() > MCP_ERROR_MESSAGE_MAX_BYTES {
529 let cut = crate::tool::builtins::floor_char_boundary(
530 &err.message,
531 MCP_ERROR_MESSAGE_MAX_BYTES,
532 );
533 format!("{}…[truncated]", &err.message[..cut])
534 } else {
535 err.message
536 };
537 return Err(Error::Mcp(format!(
538 "[mcp_server_error code={}] {}",
539 err.code, truncated
540 )));
541 }
542
543 rpc_response
544 .result
545 .ok_or_else(|| Error::Mcp("Response missing both result and error".into()))
546}
547
548const MCP_STDIO_LINE_MAX_BYTES: u64 = 16 * 1024 * 1024;
553
554async fn read_stdio_response<R: tokio::io::AsyncBufRead + Unpin>(
560 reader: &mut R,
561 expected_id: u64,
562) -> Result<String, Error> {
563 use tokio::io::AsyncBufReadExt;
564 let mut buf = String::new();
565 loop {
566 buf.clear();
567 let max_bytes = MCP_STDIO_LINE_MAX_BYTES as usize;
572 let mut total: usize = 0;
573 let mut got_eof = true;
574 loop {
575 let chunk = reader
576 .fill_buf()
577 .await
578 .map_err(|e| Error::Mcp(format!("stdio read error: {e}")))?;
579 if chunk.is_empty() {
580 break; }
582 got_eof = false;
583 let nl_pos = chunk.iter().position(|&b| b == b'\n');
584 let take = nl_pos.map(|i| i + 1).unwrap_or(chunk.len());
585 if total.saturating_add(take) > max_bytes {
586 return Err(Error::Mcp(format!(
587 "MCP stdio line exceeded cap of {MCP_STDIO_LINE_MAX_BYTES} bytes (F-MCP-4)"
588 )));
589 }
590 buf.push_str(&String::from_utf8_lossy(&chunk[..take]));
592 total += take;
593 reader.consume(take);
594 if nl_pos.is_some() {
595 break;
596 }
597 }
598 if got_eof && buf.is_empty() {
599 return Err(Error::Mcp("MCP stdio server closed unexpectedly".into()));
600 }
601 let trimmed = buf.trim();
602 if trimmed.is_empty() {
603 continue;
604 }
605
606 let value: Value = match serde_json::from_str(trimmed) {
608 Ok(v) => v,
609 Err(_) => continue,
610 };
611
612 match value.get("id") {
614 None | Some(&Value::Null) => {
615 if value.get("method").and_then(|m| m.as_str()) == Some("notifications/message") {
616 handle_log_notification(&value);
617 }
618 continue;
619 }
620 _ => {}
621 }
622
623 if value.get("id").and_then(|v| v.as_u64()) == Some(expected_id) {
624 return Ok(trimmed.to_string());
625 }
626 }
628}
629
630pub trait AuthProvider: Send + Sync {
637 fn auth_header_for<'a>(
640 &'a self,
641 user_id: &'a str,
642 tenant_id: &'a str,
643 ) -> Pin<Box<dyn Future<Output = Result<Option<String>, Error>> + Send + 'a>>;
644
645 fn auth_header_for_resource<'a>(
651 &'a self,
652 user_id: &'a str,
653 tenant_id: &'a str,
654 _resource: Option<&'a str>,
655 _scopes: Option<&'a [String]>,
656 ) -> Pin<Box<dyn Future<Output = Result<Option<String>, Error>> + Send + 'a>> {
657 self.auth_header_for(user_id, tenant_id)
658 }
659
660 fn has_credentials(&self, _user_id: &str, _tenant_id: &str) -> bool {
667 true
668 }
669}
670
671pub struct StaticAuthProvider {
673 header: Option<String>,
674}
675
676impl StaticAuthProvider {
677 pub fn new(header: Option<String>) -> Self {
679 Self { header }
680 }
681}
682
683impl AuthProvider for StaticAuthProvider {
684 fn auth_header_for<'a>(
685 &'a self,
686 _user_id: &'a str,
687 _tenant_id: &'a str,
688 ) -> Pin<Box<dyn Future<Output = Result<Option<String>, Error>> + Send + 'a>> {
689 Box::pin(async move { Ok(self.header.clone()) })
690 }
691}
692
693pub struct DirectAuthProvider {
696 tokens: HashMap<String, String>,
697}
698
699impl DirectAuthProvider {
700 pub fn new(tokens: HashMap<String, String>) -> Self {
702 Self { tokens }
703 }
704}
705
706impl AuthProvider for DirectAuthProvider {
707 fn auth_header_for<'a>(
708 &'a self,
709 _user_id: &'a str,
710 _tenant_id: &'a str,
711 ) -> Pin<Box<dyn Future<Output = Result<Option<String>, Error>> + Send + 'a>> {
712 Box::pin(async { Ok(None) })
715 }
716
717 fn auth_header_for_resource<'a>(
718 &'a self,
719 _user_id: &'a str,
720 _tenant_id: &'a str,
721 resource: Option<&'a str>,
722 _scopes: Option<&'a [String]>,
723 ) -> Pin<Box<dyn Future<Output = Result<Option<String>, Error>> + Send + 'a>> {
724 Box::pin(async move {
725 Ok(
726 resource
727 .and_then(|url| self.tokens.get(url).map(|token| format!("Bearer {token}"))),
728 )
729 })
730 }
731
732 fn has_credentials(&self, _user_id: &str, _tenant_id: &str) -> bool {
733 !self.tokens.is_empty()
734 }
735}
736
737pub trait AuthResolver: Send + Sync {
745 fn resolve(&self) -> Pin<Box<dyn Future<Output = Result<Option<String>, Error>> + Send + '_>>;
747}
748
749pub struct StaticAuthResolver(pub Option<String>);
751
752impl AuthResolver for StaticAuthResolver {
753 fn resolve(&self) -> Pin<Box<dyn Future<Output = Result<Option<String>, Error>> + Send + '_>> {
754 Box::pin(async move { Ok(self.0.clone()) })
755 }
756}
757
758pub struct DynamicAuthResolver {
763 provider: Arc<dyn AuthProvider>,
764 user_id: String,
765 tenant_id: String,
766 resource: Option<String>,
767 scopes: Option<Vec<String>>,
768}
769
770impl DynamicAuthResolver {
771 pub fn new(
773 provider: Arc<dyn AuthProvider>,
774 user_id: impl Into<String>,
775 tenant_id: impl Into<String>,
776 ) -> Self {
777 Self {
778 provider,
779 user_id: user_id.into(),
780 tenant_id: tenant_id.into(),
781 resource: None,
782 scopes: None,
783 }
784 }
785
786 pub fn with_resource(mut self, resource: Option<String>) -> Self {
788 self.resource = resource;
789 self
790 }
791
792 pub fn with_scopes(mut self, scopes: Option<Vec<String>>) -> Self {
794 self.scopes = scopes;
795 self
796 }
797}
798
799impl AuthResolver for DynamicAuthResolver {
800 fn resolve(&self) -> Pin<Box<dyn Future<Output = Result<Option<String>, Error>> + Send + '_>> {
801 Box::pin(async move {
802 self.provider
803 .auth_header_for_resource(
804 &self.user_id,
805 &self.tenant_id,
806 self.resource.as_deref(),
807 self.scopes.as_deref(),
808 )
809 .await
810 })
811 }
812}
813
814const TENANT_ID_HEADER: &str = "X-Tenant-ID";
816
817pub struct TokenExchangeAuthProvider {
820 client: reqwest::Client,
821 exchange_url: String,
822 client_id: String,
823 client_secret: String,
824 tenant_id: Option<String>,
827 agent_token: String,
829 scopes: Vec<String>,
832 agent_token_cache: RwLock<Option<(String, Instant)>>,
835 user_tokens: Arc<RwLock<HashMap<String, String>>>,
838 token_cache: RwLock<HashMap<TokenCacheKey, (String, Instant)>>,
843}
844
845#[derive(Debug, Clone, PartialEq, Eq, Hash)]
851struct TokenCacheKey {
852 tenant_id: String,
853 user_id: String,
854 resource: String,
855 scopes: String,
856}
857
858#[derive(Deserialize)]
860struct TokenExchangeResponse {
861 access_token: String,
862 #[serde(default)]
863 expires_in: Option<u64>,
864 #[serde(default)]
865 token_type: Option<String>,
866}
867
868const TOKEN_EXCHANGE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
870
871impl TokenExchangeAuthProvider {
872 pub fn new(
882 exchange_url: impl Into<String>,
883 client_id: impl Into<String>,
884 client_secret: impl Into<String>,
885 agent_token: impl Into<String>,
886 ) -> Self {
887 let exchange_url: String = exchange_url.into();
888 if let Err(e) =
889 crate::http::validate_url_sync(&exchange_url, crate::http::IpPolicy::default())
890 {
891 tracing::error!(
892 error = %e,
893 exchange_url = %exchange_url,
894 "TokenExchangeAuthProvider::new: invalid exchange_url; \
895 the OAuth exchange will fail at request time. \
896 Consider TokenExchangeAuthProvider::try_new for a graceful Result."
897 );
898 }
899 Self::new_unchecked(exchange_url, client_id, client_secret, agent_token)
900 }
901
902 pub fn try_new(
909 exchange_url: impl Into<String>,
910 client_id: impl Into<String>,
911 client_secret: impl Into<String>,
912 agent_token: impl Into<String>,
913 ) -> Result<Self, Error> {
914 let exchange_url: String = exchange_url.into();
915 crate::http::validate_url_sync(&exchange_url, crate::http::IpPolicy::default())
916 .map_err(|e| Error::Mcp(format!("invalid exchange_url: {e}")))?;
917 Ok(Self::new_unchecked(
918 exchange_url,
919 client_id,
920 client_secret,
921 agent_token,
922 ))
923 }
924
925 fn new_unchecked(
926 exchange_url: String,
927 client_id: impl Into<String>,
928 client_secret: impl Into<String>,
929 agent_token: impl Into<String>,
930 ) -> Self {
931 Self {
932 client: crate::http::vendor_client_builder()
938 .timeout(TOKEN_EXCHANGE_TIMEOUT)
939 .build()
940 .unwrap_or_default(),
941 exchange_url,
942 client_id: client_id.into(),
943 client_secret: client_secret.into(),
944 tenant_id: None,
945 agent_token: agent_token.into(),
946 scopes: Vec::new(),
947 agent_token_cache: RwLock::new(None),
948 user_tokens: Arc::new(RwLock::new(HashMap::new())),
949 token_cache: RwLock::new(HashMap::new()),
950 }
951 }
952
953 pub fn with_tenant_id(mut self, tenant_id: Option<String>) -> Self {
955 self.tenant_id = tenant_id;
956 self
957 }
958
959 pub fn with_scopes(mut self, scopes: Vec<String>) -> Self {
961 self.scopes = scopes;
962 self
963 }
964
965 pub fn with_user_tokens(mut self, tokens: Arc<RwLock<HashMap<String, String>>>) -> Self {
967 self.user_tokens = tokens;
968 self
969 }
970
971 pub fn user_tokens(&self) -> &Arc<RwLock<HashMap<String, String>>> {
973 &self.user_tokens
974 }
975
976 async fn ensure_valid_agent_token(&self) -> Result<String, Error> {
982 {
984 let cache = self
985 .agent_token_cache
986 .read()
987 .map_err(|e| Error::Mcp(format!("agent_token_cache lock poisoned: {e}")))?;
988 if let Some((token, expires_at)) = &*cache
989 && Instant::now() < *expires_at
990 {
991 return Ok(token.clone());
992 }
993 }
994 if let Some(tenant_id) = &self.tenant_id {
996 let scope = if self.scopes.is_empty() {
997 "openid".to_string()
998 } else {
999 self.scopes.join(" ")
1000 };
1001 let response = self
1002 .client
1003 .post(&self.exchange_url)
1004 .header(TENANT_ID_HEADER, tenant_id)
1005 .form(&[
1006 ("grant_type", "client_credentials"),
1007 ("client_id", &self.client_id),
1008 ("client_secret", &self.client_secret),
1009 ("scope", &scope),
1010 ])
1011 .send()
1012 .await
1013 .map_err(|e| Error::Mcp(format!("Agent token fetch failed: {e}")))?;
1014
1015 let status = response.status();
1016 if !status.is_success() {
1017 let body = response.text().await.unwrap_or_default();
1018 let body = redact_idp_body(&body);
1021 let cut = crate::tool::builtins::floor_char_boundary(&body, 512);
1022 return Err(Error::Mcp(format!(
1023 "Agent token fetch failed (HTTP {status}): {}",
1024 &body[..cut]
1025 )));
1026 }
1027
1028 let resp: TokenExchangeResponse = response
1029 .json()
1030 .await
1031 .map_err(|e| Error::Mcp(format!("Agent token response parse error: {e}")))?;
1032
1033 let ttl = resp.expires_in.unwrap_or(300).min(3600).saturating_sub(30);
1034 let expires_at = Instant::now() + Duration::from_secs(ttl);
1035 *self
1036 .agent_token_cache
1037 .write()
1038 .map_err(|e| Error::Mcp(format!("agent_token_cache lock poisoned: {e}")))? =
1039 Some((resp.access_token.clone(), expires_at));
1040 return Ok(resp.access_token);
1041 }
1042 Ok(self.agent_token.clone())
1044 }
1045}
1046
1047impl AuthProvider for TokenExchangeAuthProvider {
1048 fn auth_header_for<'a>(
1049 &'a self,
1050 user_id: &'a str,
1051 tenant_id: &'a str,
1052 ) -> Pin<Box<dyn Future<Output = Result<Option<String>, Error>> + Send + 'a>> {
1053 Box::pin(async move {
1054 let cache_key = TokenCacheKey {
1058 tenant_id: tenant_id.to_string(),
1059 user_id: user_id.to_string(),
1060 resource: String::new(),
1061 scopes: String::new(),
1062 };
1063 if let Ok(cache) = self.token_cache.read()
1064 && let Some((token, expires_at)) = cache.get(&cache_key)
1065 && Instant::now() < *expires_at
1066 {
1067 return Ok(Some(format!("Bearer {token}")));
1068 }
1069
1070 let token_key = format!("{tenant_id}:{user_id}");
1071 let subject_token = {
1072 let tokens = self
1073 .user_tokens
1074 .read()
1075 .map_err(|e| Error::Mcp(format!("user_tokens lock poisoned: {e}")))?;
1076 tokens.get(&token_key).cloned().ok_or_else(|| {
1077 Error::Mcp(format!(
1078 "No subject token found for user '{user_id}' in tenant '{tenant_id}'"
1079 ))
1080 })?
1081 };
1082
1083 let agent_token = self.ensure_valid_agent_token().await?;
1084 let response = self
1085 .client
1086 .post(&self.exchange_url)
1087 .header(TENANT_ID_HEADER, tenant_id)
1088 .form(&[
1089 (
1090 "grant_type",
1091 "urn:ietf:params:oauth:grant-type:token-exchange",
1092 ),
1093 ("subject_token", &subject_token),
1094 (
1095 "subject_token_type",
1096 "urn:ietf:params:oauth:token-type:access_token",
1097 ),
1098 ("actor_token", &agent_token),
1099 (
1100 "actor_token_type",
1101 "urn:ietf:params:oauth:token-type:access_token",
1102 ),
1103 ("client_id", &self.client_id),
1104 ("client_secret", &self.client_secret),
1105 ])
1106 .send()
1107 .await
1108 .map_err(|e| Error::Mcp(format!("Token exchange request failed: {e}")))?;
1109
1110 let status = response.status();
1111 if !status.is_success() {
1112 let body = response.text().await.unwrap_or_default();
1113 let cut = crate::tool::builtins::floor_char_boundary(&body, 512);
1115 return Err(Error::Mcp(format!(
1116 "Token exchange failed (HTTP {status}): {}",
1117 &body[..cut]
1118 )));
1119 }
1120
1121 let token_response: TokenExchangeResponse = response
1122 .json()
1123 .await
1124 .map_err(|e| Error::Mcp(format!("Token exchange response parse error: {e}")))?;
1125
1126 let ttl = token_response.expires_in.unwrap_or(300).min(3600);
1128 let now = Instant::now();
1130 let expires_at = now + Duration::from_secs(ttl.saturating_sub(30));
1131 if let Ok(mut cache) = self.token_cache.write() {
1132 cache.retain(|_, (_, exp)| now < *exp);
1134 cache.insert(cache_key, (token_response.access_token.clone(), expires_at));
1135 }
1136
1137 let token_type = token_response.token_type.as_deref().unwrap_or("Bearer");
1138 Ok(Some(format!(
1139 "{token_type} {}",
1140 token_response.access_token
1141 )))
1142 })
1143 }
1144
1145 fn has_credentials(&self, user_id: &str, tenant_id: &str) -> bool {
1146 let token_key = format!("{tenant_id}:{user_id}");
1147 self.user_tokens
1148 .read()
1149 .map(|tokens| tokens.contains_key(&token_key))
1150 .unwrap_or(false)
1151 }
1152
1153 fn auth_header_for_resource<'a>(
1154 &'a self,
1155 user_id: &'a str,
1156 tenant_id: &'a str,
1157 resource: Option<&'a str>,
1158 scopes: Option<&'a [String]>,
1159 ) -> Pin<Box<dyn Future<Output = Result<Option<String>, Error>> + Send + 'a>> {
1160 Box::pin(async move {
1161 let resource_key = resource.unwrap_or("");
1163 let scopes_key = scopes
1164 .map(|s| {
1165 let mut sorted = s.to_vec();
1166 sorted.sort();
1167 sorted.join(",")
1168 })
1169 .unwrap_or_default();
1170 let cache_key = TokenCacheKey {
1174 tenant_id: tenant_id.to_string(),
1175 user_id: user_id.to_string(),
1176 resource: resource_key.to_string(),
1177 scopes: scopes_key.clone(),
1178 };
1179
1180 if let Ok(cache) = self.token_cache.read()
1182 && let Some((token, expires_at)) = cache.get(&cache_key)
1183 && Instant::now() < *expires_at
1184 {
1185 return Ok(Some(format!("Bearer {token}")));
1186 }
1187
1188 let token_key = format!("{tenant_id}:{user_id}");
1189 let subject_token = {
1190 let tokens = self
1191 .user_tokens
1192 .read()
1193 .map_err(|e| Error::Mcp(format!("user_tokens lock poisoned: {e}")))?;
1194 tokens.get(&token_key).cloned().ok_or_else(|| {
1195 Error::Mcp(format!(
1196 "No subject token found for user '{user_id}' in tenant '{tenant_id}'"
1197 ))
1198 })?
1199 };
1200
1201 let agent_token = self.ensure_valid_agent_token().await?;
1202
1203 let mut form_params: Vec<(&str, String)> = vec![
1205 (
1206 "grant_type",
1207 "urn:ietf:params:oauth:grant-type:token-exchange".into(),
1208 ),
1209 ("subject_token", subject_token),
1210 (
1211 "subject_token_type",
1212 "urn:ietf:params:oauth:token-type:access_token".into(),
1213 ),
1214 ("actor_token", agent_token),
1215 (
1216 "actor_token_type",
1217 "urn:ietf:params:oauth:token-type:access_token".into(),
1218 ),
1219 ("client_id", self.client_id.clone()),
1220 ("client_secret", self.client_secret.clone()),
1221 ];
1222 if let Some(r) = resource {
1223 form_params.push(("resource", r.to_string()));
1224 }
1225 if let Some(s) = scopes
1226 && !s.is_empty()
1227 {
1228 form_params.push(("scope", s.join(" ")));
1229 }
1230
1231 let response = self
1232 .client
1233 .post(&self.exchange_url)
1234 .header(TENANT_ID_HEADER, tenant_id)
1235 .form(&form_params)
1236 .send()
1237 .await
1238 .map_err(|e| Error::Mcp(format!("Token exchange request failed: {e}")))?;
1239
1240 let status = response.status();
1241 if !status.is_success() {
1242 let body = response.text().await.unwrap_or_default();
1243 let body = redact_idp_body(&body);
1246 let cut = crate::tool::builtins::floor_char_boundary(&body, 512);
1247 return Err(Error::Mcp(format!(
1248 "Token exchange failed (HTTP {status}): {}",
1249 &body[..cut]
1250 )));
1251 }
1252
1253 let token_response: TokenExchangeResponse = response
1254 .json()
1255 .await
1256 .map_err(|e| Error::Mcp(format!("Token exchange response parse error: {e}")))?;
1257
1258 let ttl = token_response.expires_in.unwrap_or(300).min(3600);
1259 let now = Instant::now();
1260 let expires_at = now + Duration::from_secs(ttl.saturating_sub(30));
1261 if let Ok(mut cache) = self.token_cache.write() {
1262 cache.retain(|_, (_, exp)| now < *exp);
1263 cache.insert(cache_key, (token_response.access_token.clone(), expires_at));
1264 }
1265
1266 let token_type = token_response.token_type.as_deref().unwrap_or("Bearer");
1267 Ok(Some(format!(
1268 "{token_type} {}",
1269 token_response.access_token
1270 )))
1271 })
1272 }
1273}
1274
1275struct HttpTransport {
1279 client: reqwest::Client,
1280 endpoint: String,
1281 session_id: RwLock<Option<String>>,
1282 next_id: AtomicU64,
1283 auth_header: Option<String>,
1284}
1285
1286impl HttpTransport {
1287 fn next_id(&self) -> u64 {
1288 self.next_id.fetch_add(1, Ordering::Relaxed)
1289 }
1290
1291 fn read_session_id(&self) -> Result<Option<String>, Error> {
1293 Ok(self
1294 .session_id
1295 .read()
1296 .map_err(|e| Error::Mcp(format!("Lock poisoned: {e}")))?
1297 .clone())
1298 }
1299
1300 fn update_session_id(&self, response: &reqwest::Response) -> Result<(), Error> {
1302 if let Some(new_sid) = response
1303 .headers()
1304 .get("Mcp-Session-Id")
1305 .and_then(|v| v.to_str().ok())
1306 {
1307 *self
1308 .session_id
1309 .write()
1310 .map_err(|e| Error::Mcp(format!("Lock poisoned: {e}")))? =
1311 Some(new_sid.to_string());
1312 }
1313 Ok(())
1314 }
1315
1316 async fn rpc(
1317 &self,
1318 method: &str,
1319 params: Option<Value>,
1320 auth_override: Option<&str>,
1321 ) -> Result<Value, Error> {
1322 let id = self.next_id();
1323 let request = JsonRpcRequest {
1324 jsonrpc: "2.0",
1325 method: method.to_string(),
1326 params,
1327 id,
1328 };
1329
1330 let mut builder = self
1331 .client
1332 .post(&self.endpoint)
1333 .header("Accept", "application/json, text/event-stream")
1334 .json(&request);
1335
1336 if let Some(sid) = self.read_session_id()? {
1337 builder = builder.header("Mcp-Session-Id", sid);
1338 }
1339 let effective_auth = auth_override.or(self.auth_header.as_deref());
1341 if let Some(auth) = effective_auth {
1342 builder = builder.header("Authorization", auth);
1343 }
1344
1345 let response = builder.send().await?;
1346 self.update_session_id(&response)?;
1347
1348 let status = response.status();
1349 let content_type = response
1350 .headers()
1351 .get("content-type")
1352 .and_then(|v| v.to_str().ok())
1353 .unwrap_or("")
1354 .to_string();
1355 const MCP_HTTP_BODY_MAX_BYTES: usize = 16 * 1024 * 1024;
1360 let body = crate::http::read_text_capped(response, MCP_HTTP_BODY_MAX_BYTES).await?;
1361
1362 if !status.is_success() {
1363 return Err(Error::Mcp(format!("HTTP {}: {}", status.as_u16(), body)));
1364 }
1365
1366 let json_str = if content_type.contains("text/event-stream") {
1367 let events = extract_sse_events(&body)?;
1368 find_rpc_response(&events, id)?
1369 } else {
1370 body
1371 };
1372
1373 process_rpc_response(&json_str)
1374 }
1375
1376 async fn notify(
1377 &self,
1378 method: &str,
1379 params: Option<Value>,
1380 auth_override: Option<&str>,
1381 ) -> Result<(), Error> {
1382 let notification = JsonRpcNotification {
1383 jsonrpc: "2.0",
1384 method: method.to_string(),
1385 params,
1386 };
1387
1388 let mut builder = self
1389 .client
1390 .post(&self.endpoint)
1391 .header("Accept", "application/json, text/event-stream")
1392 .json(¬ification);
1393
1394 if let Some(sid) = self.read_session_id()? {
1395 builder = builder.header("Mcp-Session-Id", sid);
1396 }
1397 let effective_auth = auth_override.or(self.auth_header.as_deref());
1398 if let Some(auth) = effective_auth {
1399 builder = builder.header("Authorization", auth);
1400 }
1401
1402 let response = builder.send().await?;
1403 self.update_session_id(&response)?;
1404
1405 let status = response.status();
1406 if !status.is_success() {
1407 let body = response.text().await?;
1408 return Err(Error::Mcp(format!(
1409 "Notification HTTP {}: {}",
1410 status.as_u16(),
1411 body
1412 )));
1413 }
1414
1415 let _ = response.bytes().await;
1417
1418 Ok(())
1419 }
1420}
1421
1422struct StdioIo {
1429 stdin: tokio::process::ChildStdin,
1430 reader: tokio::io::BufReader<tokio::process::ChildStdout>,
1431 _process: tokio::process::Child,
1432}
1433
1434struct StdioTransport {
1439 io: tokio::sync::Mutex<StdioIo>,
1440 next_id: AtomicU64,
1441}
1442
1443impl StdioTransport {
1444 fn next_id(&self) -> u64 {
1445 self.next_id.fetch_add(1, Ordering::Relaxed)
1446 }
1447
1448 async fn rpc(&self, method: &str, params: Option<Value>) -> Result<Value, Error> {
1449 let id = self.next_id();
1450 let request = JsonRpcRequest {
1451 jsonrpc: "2.0",
1452 method: method.to_string(),
1453 params,
1454 id,
1455 };
1456 let line = serde_json::to_string(&request)? + "\n";
1457
1458 let mut io = self.io.lock().await;
1461 let json_str = tokio::time::timeout(*REQUEST_TIMEOUT, async {
1462 io.stdin
1463 .write_all(line.as_bytes())
1464 .await
1465 .map_err(|e| Error::Mcp(format!("stdio write error: {e}")))?;
1466 io.stdin
1467 .flush()
1468 .await
1469 .map_err(|e| Error::Mcp(format!("stdio flush error: {e}")))?;
1470 read_stdio_response(&mut io.reader, id).await
1471 })
1472 .await
1473 .map_err(|_| {
1474 Error::Mcp(format!(
1475 "MCP stdio server timed out after {}s for request {id} — slow server \
1476 startup? Raise HEARTBIT_MCP_TIMEOUT_SECS.",
1477 REQUEST_TIMEOUT.as_secs()
1478 ))
1479 })??;
1480 process_rpc_response(&json_str)
1481 }
1482
1483 async fn notify(&self, method: &str, params: Option<Value>) -> Result<(), Error> {
1484 let notification = JsonRpcNotification {
1485 jsonrpc: "2.0",
1486 method: method.to_string(),
1487 params,
1488 };
1489 let line = serde_json::to_string(¬ification)? + "\n";
1490
1491 let mut io = self.io.lock().await;
1492 tokio::time::timeout(*REQUEST_TIMEOUT, async {
1493 io.stdin
1494 .write_all(line.as_bytes())
1495 .await
1496 .map_err(|e| Error::Mcp(format!("stdio write error: {e}")))?;
1497 io.stdin
1498 .flush()
1499 .await
1500 .map_err(|e| Error::Mcp(format!("stdio flush error: {e}")))?;
1501 Ok::<(), Error>(())
1502 })
1503 .await
1504 .map_err(|_| {
1505 Error::Mcp(format!(
1506 "MCP stdio notification timed out after {}s",
1507 REQUEST_TIMEOUT.as_secs()
1508 ))
1509 })??;
1510 Ok(())
1511 }
1512}
1513
1514enum Transport {
1518 Http(HttpTransport),
1519 Stdio(Box<StdioTransport>),
1520}
1521
1522impl Transport {
1523 async fn rpc(&self, method: &str, params: Option<Value>) -> Result<Value, Error> {
1524 self.rpc_with_auth(method, params, None).await
1525 }
1526
1527 async fn rpc_with_auth(
1528 &self,
1529 method: &str,
1530 params: Option<Value>,
1531 auth_override: Option<&str>,
1532 ) -> Result<Value, Error> {
1533 match self {
1534 Transport::Http(t) => t.rpc(method, params, auth_override).await,
1535 Transport::Stdio(t) => t.rpc(method, params).await,
1537 }
1538 }
1539
1540 async fn notify(&self, method: &str, params: Option<Value>) -> Result<(), Error> {
1541 self.notify_with_auth(method, params, None).await
1542 }
1543
1544 async fn notify_with_auth(
1545 &self,
1546 method: &str,
1547 params: Option<Value>,
1548 auth_override: Option<&str>,
1549 ) -> Result<(), Error> {
1550 match self {
1551 Transport::Http(t) => t.notify(method, params, auth_override).await,
1552 Transport::Stdio(t) => t.notify(method, params).await,
1553 }
1554 }
1555
1556 async fn call_tool_with_auth(
1558 &self,
1559 name: &str,
1560 arguments: Value,
1561 auth_override: Option<&str>,
1562 ) -> Result<ToolOutput, Error> {
1563 let arguments = if arguments.is_null() {
1566 serde_json::json!({})
1567 } else {
1568 arguments
1569 };
1570 let params = serde_json::json!({
1571 "name": name,
1572 "arguments": arguments,
1573 });
1574
1575 let result_value = self
1576 .rpc_with_auth("tools/call", Some(params), auth_override)
1577 .await?;
1578 let result: McpCallToolResult = serde_json::from_value(result_value)?;
1579 Ok(mcp_result_to_tool_output(result))
1580 }
1581}
1582
1583struct McpTool {
1586 transport: Arc<Transport>,
1587 def: ToolDefinition,
1588 auth_resolver: Option<Arc<dyn AuthResolver>>,
1591}
1592
1593impl Tool for McpTool {
1594 fn definition(&self) -> ToolDefinition {
1595 self.def.clone()
1596 }
1597
1598 fn execute(
1599 &self,
1600 _ctx: &crate::ExecutionContext,
1601 input: Value,
1602 ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>> {
1603 Box::pin(async move {
1604 let auth = if let Some(resolver) = &self.auth_resolver {
1605 resolver.resolve().await?
1606 } else {
1607 None
1608 };
1609 match self
1610 .transport
1611 .call_tool_with_auth(&self.def.name, input, auth.as_deref())
1612 .await
1613 {
1614 Ok(output) => Ok(output),
1615 Err(e) => {
1616 tracing::warn!(
1617 tool = %self.def.name,
1618 error = %e,
1619 "MCP tool call failed"
1620 );
1621 Ok(ToolOutput::error(e.to_string()))
1622 }
1623 }
1624 })
1625 }
1626}
1627
1628struct McpResourceTool {
1637 transport: Arc<Transport>,
1638 resource: McpResourceDef,
1639 tool_name: String,
1640 auth_resolver: Option<Arc<dyn AuthResolver>>,
1641}
1642
1643impl Tool for McpResourceTool {
1644 fn definition(&self) -> ToolDefinition {
1645 let desc = self
1646 .resource
1647 .description
1648 .clone()
1649 .unwrap_or_else(|| format!("Read MCP resource: {}", self.resource.uri));
1650 ToolDefinition {
1651 name: self.tool_name.clone(),
1652 description: desc,
1653 input_schema: serde_json::json!({
1654 "type": "object",
1655 "properties": {},
1656 }),
1657 }
1658 }
1659
1660 fn execute(
1661 &self,
1662 _ctx: &crate::ExecutionContext,
1663 _input: Value,
1664 ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>> {
1665 Box::pin(async move {
1666 const ALLOWED_SCHEMES: &[&str] = &["mcp", "https", "http", "resource", "memory"];
1672 let scheme = self
1673 .resource
1674 .uri
1675 .split(':')
1676 .next()
1677 .unwrap_or("")
1678 .to_ascii_lowercase();
1679 if !ALLOWED_SCHEMES.iter().any(|s| *s == scheme) {
1680 return Ok(ToolOutput::error(format!(
1681 "MCP resource URI scheme {scheme:?} is not allowed; \
1682 refused (F-MCP-10). uri={}",
1683 self.resource.uri
1684 )));
1685 }
1686 let auth = if let Some(resolver) = &self.auth_resolver {
1687 resolver.resolve().await?
1688 } else {
1689 None
1690 };
1691 let params = serde_json::json!({ "uri": self.resource.uri });
1692 match self
1693 .transport
1694 .rpc_with_auth("resources/read", Some(params), auth.as_deref())
1695 .await
1696 {
1697 Ok(value) => {
1698 let result: McpResourceReadResult = serde_json::from_value(value)?;
1699 let text: String = result
1700 .contents
1701 .iter()
1702 .filter_map(|c| c.text.as_deref())
1703 .collect::<Vec<_>>()
1704 .join("\n");
1705 if text.is_empty() {
1706 Ok(ToolOutput::success(format!(
1707 "[Resource {} returned no text content]",
1708 self.resource.uri
1709 )))
1710 } else {
1711 Ok(ToolOutput::success(text))
1712 }
1713 }
1714 Err(e) => {
1715 tracing::warn!(
1716 resource = %self.resource.uri,
1717 error = %e,
1718 "MCP resource read failed"
1719 );
1720 Ok(ToolOutput::error(e.to_string()))
1721 }
1722 }
1723 })
1724 }
1725}
1726
1727struct McpPromptTool {
1731 transport: Arc<Transport>,
1732 prompt: McpPromptDef,
1733 tool_name: String,
1734 auth_resolver: Option<Arc<dyn AuthResolver>>,
1735}
1736
1737impl Tool for McpPromptTool {
1738 fn definition(&self) -> ToolDefinition {
1739 let desc = self
1740 .prompt
1741 .description
1742 .clone()
1743 .unwrap_or_else(|| format!("Get MCP prompt: {}", self.prompt.name));
1744 let mut properties = serde_json::Map::new();
1746 let mut required = Vec::new();
1747 for arg in &self.prompt.arguments {
1748 let mut prop = serde_json::Map::new();
1749 prop.insert("type".into(), serde_json::json!("string"));
1750 if let Some(desc) = &arg.description {
1751 prop.insert("description".into(), serde_json::json!(desc));
1752 }
1753 properties.insert(arg.name.clone(), Value::Object(prop));
1754 if arg.required {
1755 required.push(serde_json::json!(arg.name));
1756 }
1757 }
1758 let mut schema = serde_json::json!({
1759 "type": "object",
1760 "properties": properties,
1761 });
1762 if !required.is_empty() {
1763 schema["required"] = Value::Array(required);
1764 }
1765 ToolDefinition {
1766 name: self.tool_name.clone(),
1767 description: desc,
1768 input_schema: schema,
1769 }
1770 }
1771
1772 fn execute(
1773 &self,
1774 _ctx: &crate::ExecutionContext,
1775 input: Value,
1776 ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>> {
1777 Box::pin(async move {
1778 let auth = if let Some(resolver) = &self.auth_resolver {
1779 resolver.resolve().await?
1780 } else {
1781 None
1782 };
1783 let arguments = if input.is_null() || input.as_object().is_some_and(|m| m.is_empty()) {
1784 None
1785 } else {
1786 Some(input)
1787 };
1788 let mut params = serde_json::json!({ "name": self.prompt.name });
1789 if let Some(args) = arguments {
1790 params["arguments"] = args;
1791 }
1792 match self
1793 .transport
1794 .rpc_with_auth("prompts/get", Some(params), auth.as_deref())
1795 .await
1796 {
1797 Ok(value) => {
1798 let result: McpPromptGetResult = serde_json::from_value(value)?;
1799 let text: String = result
1800 .messages
1801 .iter()
1802 .map(|m| {
1803 let content = m.content.text.as_deref().unwrap_or("");
1804 format!("[{}] {}", m.role, content)
1805 })
1806 .collect::<Vec<_>>()
1807 .join("\n");
1808 Ok(ToolOutput::success(text))
1809 }
1810 Err(e) => {
1811 tracing::warn!(
1812 prompt = %self.prompt.name,
1813 error = %e,
1814 "MCP prompt get failed"
1815 );
1816 Ok(ToolOutput::error(e.to_string()))
1817 }
1818 }
1819 })
1820 }
1821}
1822
1823#[derive(Debug, Clone, Deserialize)]
1829#[serde(rename_all = "camelCase")]
1830pub struct SamplingRequest {
1831 pub messages: Vec<SamplingMessage>,
1833 #[serde(default)]
1835 pub model_preferences: Option<SamplingModelPreferences>,
1836 #[serde(default)]
1838 pub system_prompt: Option<String>,
1839 #[serde(default)]
1841 pub max_tokens: Option<u32>,
1842}
1843
1844#[derive(Debug, Clone, Serialize, Deserialize)]
1846pub struct SamplingMessage {
1847 pub role: String,
1849 pub content: SamplingContent,
1851}
1852
1853#[derive(Debug, Clone, Serialize, Deserialize)]
1855pub struct SamplingContent {
1856 #[serde(rename = "type")]
1858 pub content_type: String,
1859 #[serde(default)]
1861 pub text: Option<String>,
1862}
1863
1864#[derive(Debug, Clone, Deserialize)]
1869#[serde(rename_all = "camelCase")]
1870pub struct SamplingModelPreferences {
1871 #[serde(default)]
1873 pub hints: Vec<SamplingModelHint>,
1874}
1875
1876#[derive(Debug, Clone, Deserialize)]
1881pub struct SamplingModelHint {
1882 #[serde(default)]
1884 pub name: Option<String>,
1885}
1886
1887#[derive(Debug, Serialize)]
1889#[serde(rename_all = "camelCase")]
1890#[allow(dead_code)]
1891struct SamplingResponse {
1892 role: String,
1893 content: SamplingContent,
1894 model: String,
1895}
1896
1897pub type SamplingHandler = Arc<
1901 dyn Fn(SamplingRequest) -> Pin<Box<dyn Future<Output = Result<(String, String), Error>> + Send>>
1902 + Send
1903 + Sync,
1904>;
1905
1906fn sanitize_tool_name(name: &str) -> String {
1908 name.chars()
1909 .map(|c| {
1910 if c.is_alphanumeric() || c == '_' {
1911 c
1912 } else {
1913 '_'
1914 }
1915 })
1916 .collect()
1917}
1918
1919#[derive(Debug, Clone, Serialize, Deserialize)]
1924pub struct McpRoot {
1925 pub uri: String,
1927 #[serde(default, skip_serializing_if = "Option::is_none")]
1929 pub name: Option<String>,
1930}
1931
1932pub struct McpClient {
1938 transport: Arc<Transport>,
1939 tools: Vec<McpToolDef>,
1940 resources: Vec<McpResourceDef>,
1941 prompts: Vec<McpPromptDef>,
1942 capabilities: ServerCapabilities,
1943 sampling_handler: Option<SamplingHandler>,
1944 roots: Vec<McpRoot>,
1947}
1948
1949impl McpClient {
1950 pub fn roots(&self) -> &[McpRoot] {
1952 &self.roots
1953 }
1954
1955 pub async fn connect(endpoint: &str) -> Result<Self, Error> {
1959 Self::connect_http(endpoint, None).await
1960 }
1961
1962 pub async fn connect_with_auth(
1968 endpoint: &str,
1969 auth_header: impl Into<String>,
1970 ) -> Result<Self, Error> {
1971 Self::connect_http(endpoint, Some(auth_header.into())).await
1972 }
1973
1974 pub fn with_sampling(mut self, handler: SamplingHandler) -> Self {
1978 self.sampling_handler = Some(handler);
1979 self
1980 }
1981
1982 pub fn with_roots(mut self, roots: Vec<McpRoot>) -> Self {
1984 self.roots = roots;
1985 self
1986 }
1987
1988 pub async fn send_roots_changed(&self) -> Result<(), Error> {
1990 self.transport
1991 .notify("notifications/roots/list_changed", None)
1992 .await
1993 }
1994
1995 pub async fn connect_stdio(
2001 command: &str,
2002 args: &[String],
2003 env: &HashMap<String, String>,
2004 ) -> Result<Self, Error> {
2005 let mut cmd = tokio::process::Command::new(command);
2006 cmd.args(args)
2007 .envs(env.iter())
2008 .stdin(std::process::Stdio::piped())
2009 .stdout(std::process::Stdio::piped())
2010 .stderr(std::process::Stdio::piped())
2011 .kill_on_drop(true);
2012
2013 let mut child = cmd.spawn().map_err(|e| {
2014 Error::Mcp(format!("Failed to spawn MCP stdio server '{command}': {e}"))
2015 })?;
2016
2017 let stdin = child
2018 .stdin
2019 .take()
2020 .ok_or_else(|| Error::Mcp("Failed to capture stdin of MCP server".into()))?;
2021 let stdout = child
2022 .stdout
2023 .take()
2024 .ok_or_else(|| Error::Mcp("Failed to capture stdout of MCP server".into()))?;
2025
2026 if let Some(stderr) = child.stderr.take() {
2028 tokio::spawn(async move {
2029 let mut reader = tokio::io::BufReader::new(stderr);
2030 let mut line = String::new();
2031 loop {
2032 line.clear();
2033 match reader.read_line(&mut line).await {
2034 Ok(0) | Err(_) => break,
2035 Ok(_) => {
2036 let trimmed = line.trim();
2037 if !trimmed.is_empty() {
2038 tracing::debug!(
2039 target: "mcp_stdio_stderr",
2040 "{}",
2041 trimmed
2042 );
2043 }
2044 }
2045 }
2046 }
2047 });
2048 }
2049
2050 let transport = Arc::new(Transport::Stdio(Box::new(StdioTransport {
2051 io: tokio::sync::Mutex::new(StdioIo {
2052 stdin,
2053 reader: tokio::io::BufReader::new(stdout),
2054 _process: child,
2055 }),
2056 next_id: AtomicU64::new(0),
2057 })));
2058
2059 Self::handshake_and_discover(transport).await
2060 }
2061
2062 async fn connect_http(endpoint: &str, auth_header: Option<String>) -> Result<Self, Error> {
2063 let safe = crate::http::SafeUrl::parse(endpoint, crate::http::IpPolicy::default()).await?;
2071
2072 let client = crate::http::safe_client_builder()
2078 .timeout(*REQUEST_TIMEOUT)
2079 .build()?;
2080
2081 let transport = Arc::new(Transport::Http(HttpTransport {
2082 client,
2083 endpoint: safe.as_str().to_string(),
2084 session_id: RwLock::new(None),
2085 next_id: AtomicU64::new(0),
2086 auth_header,
2087 }));
2088
2089 Self::handshake_and_discover(transport).await
2090 }
2091
2092 async fn handshake_and_discover(transport: Arc<Transport>) -> Result<Self, Error> {
2094 let init_result = transport
2106 .rpc(
2107 "initialize",
2108 Some(serde_json::json!({
2109 "protocolVersion": PROTOCOL_VERSION,
2110 "capabilities": {
2111 "roots": { "listChanged": true }
2112 },
2113 "clientInfo": {
2114 "name": "heartbit",
2115 "version": env!("CARGO_PKG_VERSION")
2116 }
2117 })),
2118 )
2119 .await?;
2120
2121 let init: InitializeResult = serde_json::from_value(init_result)
2127 .map_err(|e| Error::Mcp(format!("malformed initialize result: {e}")))?;
2128
2129 transport.notify("notifications/initialized", None).await?;
2130
2131 let mut all_tools = Vec::new();
2133 let mut cursor: Option<String> = None;
2134 loop {
2135 let params = cursor.as_ref().map(|c| serde_json::json!({"cursor": c}));
2136 let tools_result = transport.rpc("tools/list", params).await?;
2137 let page: McpToolsListResult = serde_json::from_value(tools_result)?;
2138 all_tools.extend(page.tools);
2139 cursor = page.next_cursor;
2140 if cursor.is_none() {
2141 break;
2142 }
2143 }
2144
2145 let mut all_resources = Vec::new();
2147 if init.capabilities.resources.is_some() {
2148 let mut cursor: Option<String> = None;
2149 loop {
2150 let params = cursor.as_ref().map(|c| serde_json::json!({"cursor": c}));
2151 match transport.rpc("resources/list", params).await {
2152 Ok(value) => {
2153 let page: McpResourcesListResult = serde_json::from_value(value)?;
2154 all_resources.extend(page.resources);
2155 cursor = page.next_cursor;
2156 if cursor.is_none() {
2157 break;
2158 }
2159 }
2160 Err(e) => {
2161 tracing::warn!(error = %e, "resources/list failed, skipping resource discovery");
2162 break;
2163 }
2164 }
2165 }
2166 }
2167
2168 let mut all_prompts = Vec::new();
2170 if init.capabilities.prompts.is_some() {
2171 let mut cursor: Option<String> = None;
2172 loop {
2173 let params = cursor.as_ref().map(|c| serde_json::json!({"cursor": c}));
2174 match transport.rpc("prompts/list", params).await {
2175 Ok(value) => {
2176 let page: McpPromptsListResult = serde_json::from_value(value)?;
2177 all_prompts.extend(page.prompts);
2178 cursor = page.next_cursor;
2179 if cursor.is_none() {
2180 break;
2181 }
2182 }
2183 Err(e) => {
2184 tracing::warn!(error = %e, "prompts/list failed, skipping prompt discovery");
2185 break;
2186 }
2187 }
2188 }
2189 }
2190
2191 Ok(Self {
2192 transport,
2193 tools: all_tools,
2194 resources: all_resources,
2195 prompts: all_prompts,
2196 capabilities: init.capabilities,
2197 sampling_handler: None,
2198 roots: Vec::new(),
2199 })
2200 }
2201
2202 pub fn tool_definitions(&self) -> Vec<ToolDefinition> {
2207 self.tools.iter().map(mcp_tool_to_definition).collect()
2208 }
2209
2210 pub fn resource_definitions(&self) -> &[McpResourceDef] {
2212 &self.resources
2213 }
2214
2215 pub fn prompt_definitions(&self) -> &[McpPromptDef] {
2217 &self.prompts
2218 }
2219
2220 pub fn supports_resource_subscribe(&self) -> bool {
2222 self.capabilities
2223 .resources
2224 .as_ref()
2225 .is_some_and(|r| r.subscribe)
2226 }
2227
2228 pub async fn resource_read(&self, uri: &str) -> Result<Vec<McpResourceContent>, Error> {
2230 let params = serde_json::json!({ "uri": uri });
2231 let value = self.transport.rpc("resources/read", Some(params)).await?;
2232 let result: McpResourceReadResult = serde_json::from_value(value)?;
2233 Ok(result.contents)
2234 }
2235
2236 pub async fn set_log_level(&self, level: &str) -> Result<(), Error> {
2238 let params = serde_json::json!({ "level": level });
2239 self.transport.rpc("logging/setLevel", Some(params)).await?;
2240 Ok(())
2241 }
2242
2243 pub async fn resource_subscribe(&self, uri: &str) -> Result<(), Error> {
2245 let params = serde_json::json!({ "uri": uri });
2246 self.transport
2247 .rpc("resources/subscribe", Some(params))
2248 .await?;
2249 Ok(())
2250 }
2251
2252 pub async fn prompt_get(
2254 &self,
2255 name: &str,
2256 arguments: Option<Value>,
2257 ) -> Result<Vec<McpPromptMessage>, Error> {
2258 let mut params = serde_json::json!({ "name": name });
2259 if let Some(args) = arguments {
2260 params["arguments"] = args;
2261 }
2262 let value = self.transport.rpc("prompts/get", Some(params)).await?;
2263 let result: McpPromptGetResult = serde_json::from_value(value)?;
2264 Ok(result.messages)
2265 }
2266
2267 pub fn into_tools(self) -> Vec<Arc<dyn Tool>> {
2269 self.stamp_tools(None)
2270 }
2271
2272 pub fn into_tools_with_auth(self, resolver: Arc<dyn AuthResolver>) -> Vec<Arc<dyn Tool>> {
2277 self.stamp_tools(Some(resolver))
2278 }
2279
2280 fn stamp_tools(self, resolver: Option<Arc<dyn AuthResolver>>) -> Vec<Arc<dyn Tool>> {
2281 let transport = self.transport;
2282 self.tools
2283 .into_iter()
2284 .map(|t| {
2285 let tool: Arc<dyn Tool> = Arc::new(McpTool {
2286 transport: Arc::clone(&transport),
2287 def: mcp_tool_to_definition(&t),
2288 auth_resolver: resolver.clone(),
2289 });
2290 tool
2291 })
2292 .collect()
2293 }
2294
2295 pub fn into_resource_tools(&self) -> Vec<Arc<dyn Tool>> {
2299 self.stamp_resource_tools(None)
2300 }
2301
2302 fn stamp_resource_tools(&self, resolver: Option<Arc<dyn AuthResolver>>) -> Vec<Arc<dyn Tool>> {
2303 self.resources
2304 .iter()
2305 .map(|r| {
2306 let tool_name = format!("mcp_resource_{}", sanitize_tool_name(&r.name));
2307 let tool: Arc<dyn Tool> = Arc::new(McpResourceTool {
2308 transport: Arc::clone(&self.transport),
2309 resource: r.clone(),
2310 tool_name,
2311 auth_resolver: resolver.clone(),
2312 });
2313 tool
2314 })
2315 .collect()
2316 }
2317
2318 pub fn into_prompt_tools(&self) -> Vec<Arc<dyn Tool>> {
2322 self.stamp_prompt_tools(None)
2323 }
2324
2325 fn stamp_prompt_tools(&self, resolver: Option<Arc<dyn AuthResolver>>) -> Vec<Arc<dyn Tool>> {
2326 self.prompts
2327 .iter()
2328 .map(|p| {
2329 let tool_name = format!("mcp_prompt_{}", sanitize_tool_name(&p.name));
2330 let tool: Arc<dyn Tool> = Arc::new(McpPromptTool {
2331 transport: Arc::clone(&self.transport),
2332 prompt: p.clone(),
2333 tool_name,
2334 auth_resolver: resolver.clone(),
2335 });
2336 tool
2337 })
2338 .collect()
2339 }
2340
2341 pub fn into_all_tools(self) -> Vec<Arc<dyn Tool>> {
2343 Self::stamp_all_tools_inner(
2344 &self.transport,
2345 &self.tools,
2346 &self.resources,
2347 &self.prompts,
2348 None,
2349 )
2350 }
2351
2352 pub fn into_all_tools_with_auth(self, resolver: Arc<dyn AuthResolver>) -> Vec<Arc<dyn Tool>> {
2354 Self::stamp_all_tools_inner(
2355 &self.transport,
2356 &self.tools,
2357 &self.resources,
2358 &self.prompts,
2359 Some(resolver),
2360 )
2361 }
2362
2363 fn stamp_all_tools_inner(
2364 transport: &Arc<Transport>,
2365 tools: &[McpToolDef],
2366 resources: &[McpResourceDef],
2367 prompts: &[McpPromptDef],
2368 resolver: Option<Arc<dyn AuthResolver>>,
2369 ) -> Vec<Arc<dyn Tool>> {
2370 let mut all: Vec<Arc<dyn Tool>> = tools
2371 .iter()
2372 .map(|t| -> Arc<dyn Tool> {
2373 Arc::new(McpTool {
2374 transport: Arc::clone(transport),
2375 def: mcp_tool_to_definition(t),
2376 auth_resolver: resolver.clone(),
2377 })
2378 })
2379 .collect();
2380 for r in resources {
2381 let tool_name = format!("mcp_resource_{}", sanitize_tool_name(&r.name));
2382 all.push(Arc::new(McpResourceTool {
2383 transport: Arc::clone(transport),
2384 resource: r.clone(),
2385 tool_name,
2386 auth_resolver: resolver.clone(),
2387 }));
2388 }
2389 for p in prompts {
2390 let tool_name = format!("mcp_prompt_{}", sanitize_tool_name(&p.name));
2391 all.push(Arc::new(McpPromptTool {
2392 transport: Arc::clone(transport),
2393 prompt: p.clone(),
2394 tool_name,
2395 auth_resolver: resolver.clone(),
2396 }));
2397 }
2398 all
2399 }
2400
2401 fn into_pool_parts(
2404 self,
2405 ) -> (
2406 Arc<Transport>,
2407 Vec<McpToolDef>,
2408 Vec<McpResourceDef>,
2409 Vec<McpPromptDef>,
2410 ) {
2411 (self.transport, self.tools, self.resources, self.prompts)
2412 }
2413}
2414
2415struct PoolEntry {
2419 transport: Arc<Transport>,
2420 tools: Vec<McpToolDef>,
2421 resources: Vec<McpResourceDef>,
2422 prompts: Vec<McpPromptDef>,
2423}
2424
2425pub struct McpTransportPool {
2431 pool: RwLock<HashMap<String, PoolEntry>>,
2432}
2433
2434impl McpTransportPool {
2435 pub fn new() -> Self {
2437 Self {
2438 pool: RwLock::new(HashMap::new()),
2439 }
2440 }
2441
2442 pub async fn get_or_connect(
2447 &self,
2448 url: &str,
2449 static_auth: Option<String>,
2450 ) -> Result<Vec<ToolDefinition>, Error> {
2451 {
2453 let pool = self
2454 .pool
2455 .read()
2456 .map_err(|e| Error::Mcp(format!("transport pool lock poisoned: {e}")))?;
2457 if let Some(entry) = pool.get(url) {
2458 return Ok(entry.tools.iter().map(mcp_tool_to_definition).collect());
2459 }
2460 }
2461
2462 let client = McpClient::connect_http(url, static_auth).await?;
2464 let (transport, tools, resources, prompts) = client.into_pool_parts();
2465 let defs: Vec<ToolDefinition> = tools.iter().map(mcp_tool_to_definition).collect();
2466
2467 let entry = PoolEntry {
2468 transport,
2469 tools,
2470 resources,
2471 prompts,
2472 };
2473
2474 let mut pool = self
2475 .pool
2476 .write()
2477 .map_err(|e| Error::Mcp(format!("transport pool lock poisoned: {e}")))?;
2478 pool.insert(url.to_string(), entry);
2479
2480 Ok(defs)
2481 }
2482
2483 pub fn tools_for_user(
2487 &self,
2488 url: &str,
2489 resolver: Arc<dyn AuthResolver>,
2490 ) -> Result<Option<Vec<Arc<dyn Tool>>>, Error> {
2491 let pool = self
2492 .pool
2493 .read()
2494 .map_err(|e| Error::Mcp(format!("transport pool lock poisoned: {e}")))?;
2495 let entry = match pool.get(url) {
2496 Some(e) => e,
2497 None => return Ok(None),
2498 };
2499
2500 let resolver = Some(resolver);
2501 let mut all: Vec<Arc<dyn Tool>> = entry
2502 .tools
2503 .iter()
2504 .map(|t| -> Arc<dyn Tool> {
2505 Arc::new(McpTool {
2506 transport: Arc::clone(&entry.transport),
2507 def: mcp_tool_to_definition(t),
2508 auth_resolver: resolver.clone(),
2509 })
2510 })
2511 .collect();
2512 for r in &entry.resources {
2513 let tool_name = format!("mcp_resource_{}", sanitize_tool_name(&r.name));
2514 all.push(Arc::new(McpResourceTool {
2515 transport: Arc::clone(&entry.transport),
2516 resource: r.clone(),
2517 tool_name,
2518 auth_resolver: resolver.clone(),
2519 }));
2520 }
2521 for p in &entry.prompts {
2522 let tool_name = format!("mcp_prompt_{}", sanitize_tool_name(&p.name));
2523 all.push(Arc::new(McpPromptTool {
2524 transport: Arc::clone(&entry.transport),
2525 prompt: p.clone(),
2526 tool_name,
2527 auth_resolver: resolver.clone(),
2528 }));
2529 }
2530 Ok(Some(all))
2531 }
2532
2533 pub fn contains(&self, url: &str) -> bool {
2535 self.pool
2536 .read()
2537 .map(|p| p.contains_key(url))
2538 .unwrap_or(false)
2539 }
2540}
2541
2542impl Default for McpTransportPool {
2543 fn default() -> Self {
2544 Self::new()
2545 }
2546}
2547
2548#[cfg(test)]
2549mod tests {
2550 use super::*;
2551 use serde_json::json;
2552
2553 #[test]
2557 fn mcp_timeout_resolution() {
2558 assert_eq!(mcp_timeout_from(None), Duration::from_secs(30));
2559 assert_eq!(mcp_timeout_from(Some("90")), Duration::from_secs(90));
2560 assert_eq!(mcp_timeout_from(Some("1")), Duration::from_secs(5));
2562 assert_eq!(mcp_timeout_from(Some("10000")), Duration::from_secs(600));
2563 assert_eq!(mcp_timeout_from(Some("abc")), Duration::from_secs(30));
2565 assert_eq!(mcp_timeout_from(Some("")), Duration::from_secs(30));
2566 }
2567
2568 #[test]
2571 fn jsonrpc_request_serialization() {
2572 let req = JsonRpcRequest {
2573 jsonrpc: "2.0",
2574 method: "tools/list".to_string(),
2575 params: Some(json!({"cursor": null})),
2576 id: 42,
2577 };
2578 let json = serde_json::to_value(&req).unwrap();
2579 assert_eq!(json["jsonrpc"], "2.0");
2580 assert_eq!(json["method"], "tools/list");
2581 assert_eq!(json["id"], 42);
2582 assert!(json.get("params").is_some());
2583 }
2584
2585 #[test]
2586 fn jsonrpc_request_null_params_omitted() {
2587 let req = JsonRpcRequest {
2588 jsonrpc: "2.0",
2589 method: "tools/list".to_string(),
2590 params: None,
2591 id: 1,
2592 };
2593 let json = serde_json::to_value(&req).unwrap();
2594 assert!(json.get("params").is_none());
2595 }
2596
2597 #[test]
2598 fn jsonrpc_notification_has_no_id() {
2599 let notif = JsonRpcNotification {
2600 jsonrpc: "2.0",
2601 method: "notifications/initialized".to_string(),
2602 params: None,
2603 };
2604 let json = serde_json::to_value(¬if).unwrap();
2605 assert_eq!(json["jsonrpc"], "2.0");
2606 assert_eq!(json["method"], "notifications/initialized");
2607 assert!(json.get("id").is_none());
2608 assert!(json.get("params").is_none());
2609 }
2610
2611 #[test]
2612 fn jsonrpc_response_parses_result() {
2613 let json_str = r#"{"jsonrpc":"2.0","result":{"tools":[]},"id":1}"#;
2614 let response: JsonRpcResponse = serde_json::from_str(json_str).unwrap();
2615 assert!(response.result.is_some());
2616 assert!(response.error.is_none());
2617 assert_eq!(response.result.unwrap(), json!({"tools": []}));
2618 }
2619
2620 #[test]
2621 fn jsonrpc_response_parses_error() {
2622 let json_str =
2623 r#"{"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":1}"#;
2624 let response: JsonRpcResponse = serde_json::from_str(json_str).unwrap();
2625 assert!(response.result.is_none());
2626 let err = response.error.unwrap();
2627 assert_eq!(err.code, -32601);
2628 assert_eq!(err.message, "Method not found");
2629 }
2630
2631 #[test]
2634 fn sse_basic_extraction() {
2635 let body = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"result\":{},\"id\":1}\n\n";
2636 let events = extract_sse_events(body).unwrap();
2637 assert_eq!(events.len(), 1);
2638 assert_eq!(events[0], r#"{"jsonrpc":"2.0","result":{},"id":1}"#);
2639 }
2640
2641 #[test]
2642 fn sse_no_data_field_errors() {
2643 let body = "event: message\n\n";
2644 let err = extract_sse_events(body).unwrap_err();
2645 assert!(matches!(err, Error::Mcp(_)));
2646 assert!(err.to_string().contains("No data field"));
2647 }
2648
2649 #[test]
2650 fn sse_no_space_after_colon() {
2651 let body = "data:{\"result\":\"ok\"}\n";
2652 let events = extract_sse_events(body).unwrap();
2653 assert_eq!(events.len(), 1);
2654 assert_eq!(events[0], r#"{"result":"ok"}"#);
2655 }
2656
2657 #[test]
2658 fn sse_multiple_events_extracted() {
2659 let body =
2660 "event: message\ndata: {\"first\": true}\n\nevent: message\ndata: {\"last\": true}\n\n";
2661 let events = extract_sse_events(body).unwrap();
2662 assert_eq!(events.len(), 2);
2663 assert_eq!(events[0], r#"{"first": true}"#);
2664 assert_eq!(events[1], r#"{"last": true}"#);
2665 }
2666
2667 #[test]
2668 fn sse_multi_line_data_concatenated() {
2669 let body = "data: first line\ndata: second line\n\n";
2670 let events = extract_sse_events(body).unwrap();
2671 assert_eq!(events.len(), 1);
2672 assert_eq!(events[0], "first line\nsecond line");
2673 }
2674
2675 #[test]
2678 fn find_response_matches_by_id() {
2679 let events = vec![
2680 r#"{"jsonrpc":"2.0","method":"notifications/progress","params":{}}"#.to_string(),
2681 r#"{"jsonrpc":"2.0","result":{"tools":[]},"id":5}"#.to_string(),
2682 ];
2683 let result = find_rpc_response(&events, 5).unwrap();
2684 assert!(result.contains(r#""id":5"#));
2685 assert!(result.contains(r#""result""#));
2686 }
2687
2688 #[test]
2693 fn find_response_rejects_mismatched_id() {
2694 let events = vec![r#"{"jsonrpc":"2.0","result":{},"id":99}"#.to_string()];
2695 let err = find_rpc_response(&events, 1).unwrap_err();
2696 assert!(matches!(err, Error::Mcp(_)));
2697 }
2698
2699 #[test]
2702 fn find_response_accepts_null_id_error_only() {
2703 let events = vec![
2704 r#"{"jsonrpc":"2.0","error":{"code":-32700,"message":"parse"},"id":null}"#.to_string(),
2705 ];
2706 let result = find_rpc_response(&events, 1).unwrap();
2707 assert!(result.contains("error"));
2708 }
2709
2710 #[test]
2713 fn mcp_tools_list_parsing() {
2714 let json = json!({
2715 "tools": [
2716 {
2717 "name": "read_file",
2718 "description": "Read a file from disk",
2719 "inputSchema": {
2720 "type": "object",
2721 "properties": {
2722 "path": {"type": "string"}
2723 },
2724 "required": ["path"]
2725 }
2726 },
2727 {
2728 "name": "list_dir",
2729 "description": "List directory contents",
2730 "inputSchema": {"type": "object"}
2731 }
2732 ]
2733 });
2734
2735 let result: McpToolsListResult = serde_json::from_value(json).unwrap();
2736 assert_eq!(result.tools.len(), 2);
2737 assert_eq!(result.tools[0].name, "read_file");
2738 assert_eq!(
2739 result.tools[0].description.as_deref(),
2740 Some("Read a file from disk")
2741 );
2742 assert!(result.tools[0].input_schema.is_some());
2743 assert_eq!(result.tools[1].name, "list_dir");
2744 }
2745
2746 #[test]
2747 fn mcp_tool_to_definition_mapping() {
2748 let mcp_def = McpToolDef {
2749 name: "search".into(),
2750 description: Some("Search for files".into()),
2751 input_schema: Some(json!({
2752 "type": "object",
2753 "properties": {"query": {"type": "string"}}
2754 })),
2755 };
2756
2757 let def = mcp_tool_to_definition(&mcp_def);
2758 assert_eq!(def.name, "search");
2759 assert_eq!(def.description, "Search for files");
2760 assert_eq!(
2761 def.input_schema,
2762 json!({"type": "object", "properties": {"query": {"type": "string"}}})
2763 );
2764 }
2765
2766 #[test]
2767 fn mcp_tool_defaults_for_missing_fields() {
2768 let json = json!({"name": "minimal"});
2769 let mcp_def: McpToolDef = serde_json::from_value(json).unwrap();
2770 assert!(mcp_def.description.is_none());
2771 assert!(mcp_def.input_schema.is_none());
2772
2773 let def = mcp_tool_to_definition(&mcp_def);
2774 assert_eq!(def.name, "minimal");
2775 assert_eq!(def.description, "");
2776 assert_eq!(def.input_schema, json!({"type": "object"}));
2777 }
2778
2779 #[test]
2782 fn tool_result_success() {
2783 let result = McpCallToolResult {
2784 content: vec![McpContent {
2785 content_type: "text".into(),
2786 text: Some("file contents here".into()),
2787 }],
2788 is_error: false,
2789 };
2790
2791 let output = mcp_result_to_tool_output(result);
2792 assert_eq!(output.content, "file contents here");
2793 assert!(!output.is_error);
2794 }
2795
2796 #[test]
2797 fn tool_result_error() {
2798 let result = McpCallToolResult {
2799 content: vec![McpContent {
2800 content_type: "text".into(),
2801 text: Some("permission denied".into()),
2802 }],
2803 is_error: true,
2804 };
2805
2806 let output = mcp_result_to_tool_output(result);
2807 assert_eq!(output.content, "permission denied");
2808 assert!(output.is_error);
2809 }
2810
2811 #[test]
2812 fn tool_result_multi_text_joined() {
2813 let result = McpCallToolResult {
2814 content: vec![
2815 McpContent {
2816 content_type: "text".into(),
2817 text: Some("line one".into()),
2818 },
2819 McpContent {
2820 content_type: "text".into(),
2821 text: Some("line two".into()),
2822 },
2823 McpContent {
2824 content_type: "text".into(),
2825 text: Some("line three".into()),
2826 },
2827 ],
2828 is_error: false,
2829 };
2830
2831 let output = mcp_result_to_tool_output(result);
2832 assert_eq!(output.content, "line one\nline two\nline three");
2833 }
2834
2835 #[test]
2836 fn tool_result_images_skipped() {
2837 let result = McpCallToolResult {
2838 content: vec![
2839 McpContent {
2840 content_type: "text".into(),
2841 text: Some("caption".into()),
2842 },
2843 McpContent {
2844 content_type: "image".into(),
2845 text: None,
2846 },
2847 McpContent {
2848 content_type: "text".into(),
2849 text: Some("more text".into()),
2850 },
2851 ],
2852 is_error: false,
2853 };
2854
2855 let output = mcp_result_to_tool_output(result);
2856 assert_eq!(output.content, "caption\nmore text");
2857 }
2858
2859 #[test]
2860 fn tool_result_parses_from_json() {
2861 let json = json!({
2862 "content": [
2863 {"type": "text", "text": "hello from mcp"}
2864 ],
2865 "isError": false
2866 });
2867
2868 let result: McpCallToolResult = serde_json::from_value(json).unwrap();
2869 assert_eq!(result.content.len(), 1);
2870 assert_eq!(result.content[0].text.as_deref(), Some("hello from mcp"));
2871 assert!(!result.is_error);
2872 }
2873
2874 #[test]
2875 fn tool_result_is_error_defaults_false() {
2876 let json = json!({
2877 "content": [
2878 {"type": "text", "text": "ok"}
2879 ]
2880 });
2881
2882 let result: McpCallToolResult = serde_json::from_value(json).unwrap();
2883 assert!(!result.is_error);
2884 }
2885
2886 #[test]
2887 fn tool_result_non_text_only_shows_placeholder() {
2888 let result = McpCallToolResult {
2889 content: vec![
2890 McpContent {
2891 content_type: "image".into(),
2892 text: None,
2893 },
2894 McpContent {
2895 content_type: "resource".into(),
2896 text: None,
2897 },
2898 ],
2899 is_error: false,
2900 };
2901
2902 let output = mcp_result_to_tool_output(result);
2903 assert!(output.content.contains("2 non-text content block(s)"));
2904 assert!(!output.is_error);
2905 }
2906
2907 #[test]
2908 fn tool_result_mixed_text_and_non_text_returns_text() {
2909 let result = McpCallToolResult {
2911 content: vec![
2912 McpContent {
2913 content_type: "text".into(),
2914 text: Some("real text".into()),
2915 },
2916 McpContent {
2917 content_type: "image".into(),
2918 text: None,
2919 },
2920 ],
2921 is_error: false,
2922 };
2923
2924 let output = mcp_result_to_tool_output(result);
2925 assert_eq!(output.content, "real text");
2926 }
2927
2928 #[test]
2931 fn process_rpc_response_success() {
2932 let json_str = r#"{"jsonrpc":"2.0","result":{"tools":[]},"id":1}"#;
2933 let value = process_rpc_response(json_str).unwrap();
2934 assert_eq!(value, json!({"tools": []}));
2935 }
2936
2937 #[test]
2940 fn process_rpc_response_error_is_tagged() {
2941 let json_str =
2942 r#"{"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":1}"#;
2943 let err = process_rpc_response(json_str).unwrap_err();
2944 let s = err.to_string();
2945 assert!(s.contains("[mcp_server_error"), "missing tag prefix: {s}");
2946 assert!(s.contains("code=-32601"), "missing code: {s}");
2947 assert!(s.contains("Method not found"), "missing message: {s}");
2948 }
2949
2950 #[test]
2954 fn process_rpc_response_error_truncates_long_message() {
2955 let huge = "X".repeat(8 * 1024);
2956 let json_str =
2957 format!(r#"{{"jsonrpc":"2.0","error":{{"code":-32000,"message":"{huge}"}},"id":1}}"#);
2958 let err = process_rpc_response(&json_str).unwrap_err();
2959 let s = err.to_string();
2960 assert!(s.contains("…[truncated]"), "missing truncation marker: {s}");
2961 assert!(
2962 s.len() < 2048,
2963 "error message not bounded: {} bytes",
2964 s.len()
2965 );
2966 }
2967
2968 #[test]
2969 fn process_rpc_response_missing_both() {
2970 let json_str = r#"{"jsonrpc":"2.0","id":1}"#;
2971 let err = process_rpc_response(json_str).unwrap_err();
2972 assert!(err.to_string().contains("missing both result and error"));
2973 }
2974
2975 #[tokio::test]
2978 async fn read_stdio_response_finds_matching_id() {
2979 let (mut tx, rx) = tokio::io::duplex(4096);
2980 let mut reader = tokio::io::BufReader::new(rx);
2981
2982 tokio::spawn(async move {
2983 tx.write_all(b"{\"jsonrpc\":\"2.0\",\"result\":{\"ok\":true},\"id\":1}\n")
2984 .await
2985 .unwrap();
2986 });
2987
2988 let response = read_stdio_response(&mut reader, 1).await.unwrap();
2989 assert!(response.contains("\"id\":1"));
2990 assert!(response.contains("\"ok\":true"));
2991 }
2992
2993 #[tokio::test]
2994 async fn read_stdio_response_skips_notifications() {
2995 let (mut tx, rx) = tokio::io::duplex(4096);
2996 let mut reader = tokio::io::BufReader::new(rx);
2997
2998 tokio::spawn(async move {
2999 tx.write_all(b"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\"}\n")
3001 .await
3002 .unwrap();
3003 tx.write_all(b"{\"jsonrpc\":\"2.0\",\"result\":{\"tools\":[]},\"id\":1}\n")
3004 .await
3005 .unwrap();
3006 });
3007
3008 let response = read_stdio_response(&mut reader, 1).await.unwrap();
3009 assert!(response.contains("\"id\":1"));
3010 assert!(response.contains("\"tools\""));
3011 }
3012
3013 #[tokio::test]
3014 async fn read_stdio_response_skips_null_id() {
3015 let (mut tx, rx) = tokio::io::duplex(4096);
3016 let mut reader = tokio::io::BufReader::new(rx);
3017
3018 tokio::spawn(async move {
3019 tx.write_all(b"{\"jsonrpc\":\"2.0\",\"result\":{},\"id\":null}\n")
3021 .await
3022 .unwrap();
3023 tx.write_all(b"{\"jsonrpc\":\"2.0\",\"result\":{\"found\":true},\"id\":2}\n")
3024 .await
3025 .unwrap();
3026 });
3027
3028 let response = read_stdio_response(&mut reader, 2).await.unwrap();
3029 assert!(response.contains("\"id\":2"));
3030 assert!(response.contains("\"found\":true"));
3031 }
3032
3033 #[tokio::test]
3034 async fn read_stdio_response_skips_non_json() {
3035 let (mut tx, rx) = tokio::io::duplex(4096);
3036 let mut reader = tokio::io::BufReader::new(rx);
3037
3038 tokio::spawn(async move {
3039 tx.write_all(b"[DEBUG] initializing server...\n")
3041 .await
3042 .unwrap();
3043 tx.write_all(b"\n").await.unwrap(); tx.write_all(b"{\"jsonrpc\":\"2.0\",\"result\":{},\"id\":0}\n")
3045 .await
3046 .unwrap();
3047 });
3048
3049 let response = read_stdio_response(&mut reader, 0).await.unwrap();
3050 assert!(response.contains("\"id\":0"));
3051 }
3052
3053 #[tokio::test]
3054 async fn read_stdio_response_eof_errors() {
3055 let (tx, rx) = tokio::io::duplex(4096);
3056 let mut reader = tokio::io::BufReader::new(rx);
3057
3058 drop(tx);
3060
3061 let err = read_stdio_response(&mut reader, 0).await.unwrap_err();
3062 assert!(
3063 err.to_string().contains("closed unexpectedly"),
3064 "error: {err}"
3065 );
3066 }
3067
3068 #[tokio::test]
3069 async fn read_stdio_response_skips_wrong_id() {
3070 let (mut tx, rx) = tokio::io::duplex(4096);
3071 let mut reader = tokio::io::BufReader::new(rx);
3072
3073 tokio::spawn(async move {
3074 tx.write_all(b"{\"jsonrpc\":\"2.0\",\"result\":{\"wrong\":true},\"id\":99}\n")
3076 .await
3077 .unwrap();
3078 tx.write_all(b"{\"jsonrpc\":\"2.0\",\"result\":{\"right\":true},\"id\":3}\n")
3079 .await
3080 .unwrap();
3081 });
3082
3083 let response = read_stdio_response(&mut reader, 3).await.unwrap();
3084 assert!(response.contains("\"right\":true"));
3085 }
3086
3087 #[tokio::test]
3088 async fn read_stdio_response_timeout_prevents_hang() {
3089 let (_tx, rx) = tokio::io::duplex(4096);
3091 let mut reader = tokio::io::BufReader::new(rx);
3092
3093 let result = tokio::time::timeout(
3094 Duration::from_millis(50),
3095 read_stdio_response(&mut reader, 0),
3096 )
3097 .await;
3098
3099 assert!(result.is_err(), "should have timed out");
3100 }
3101
3102 #[test]
3105 fn http_transport_next_id_is_monotonic() {
3106 let transport = HttpTransport {
3107 client: reqwest::Client::new(),
3108 endpoint: "http://unused".to_string(),
3109 session_id: RwLock::new(None),
3110 next_id: AtomicU64::new(0),
3111 auth_header: None,
3112 };
3113
3114 assert_eq!(transport.next_id(), 0);
3115 assert_eq!(transport.next_id(), 1);
3116 assert_eq!(transport.next_id(), 2);
3117 }
3118
3119 #[test]
3122 fn mcp_tool_returns_correct_definition() {
3123 let transport = Arc::new(Transport::Http(HttpTransport {
3124 client: reqwest::Client::new(),
3125 endpoint: "http://unused".to_string(),
3126 session_id: RwLock::new(None),
3127 next_id: AtomicU64::new(0),
3128 auth_header: None,
3129 }));
3130
3131 let expected_def = ToolDefinition {
3132 name: "read_file".into(),
3133 description: "Read a file".into(),
3134 input_schema: json!({
3135 "type": "object",
3136 "properties": {"path": {"type": "string"}}
3137 }),
3138 };
3139
3140 let tool = McpTool {
3141 transport,
3142 def: expected_def.clone(),
3143 auth_resolver: None,
3144 };
3145
3146 let def = tool.definition();
3147 assert_eq!(def, expected_def);
3148 }
3149
3150 #[tokio::test]
3153 async fn static_auth_provider_returns_header() {
3154 let provider = StaticAuthProvider::new(Some("Bearer xyz".to_string()));
3155 let result = provider.auth_header_for("user1", "tenant1").await.unwrap();
3156 assert_eq!(result, Some("Bearer xyz".to_string()));
3157 }
3158
3159 #[tokio::test]
3160 async fn static_auth_provider_returns_none() {
3161 let provider = StaticAuthProvider::new(None);
3162 let result = provider.auth_header_for("user1", "tenant1").await.unwrap();
3163 assert_eq!(result, None);
3164 }
3165
3166 #[tokio::test]
3167 async fn static_auth_provider_ignores_user_tenant() {
3168 let provider = StaticAuthProvider::new(Some("Bearer abc".to_string()));
3169 let r1 = provider.auth_header_for("alice", "acme").await.unwrap();
3170 let r2 = provider.auth_header_for("bob", "globex").await.unwrap();
3171 assert_eq!(r1, r2);
3172 assert_eq!(r1, Some("Bearer abc".to_string()));
3173 }
3174
3175 #[tokio::test]
3176 async fn token_exchange_provider_missing_user_token() {
3177 let user_tokens = Arc::new(std::sync::RwLock::new(HashMap::<String, String>::new()));
3178 let provider = TokenExchangeAuthProvider::new(
3179 "https://idp.example.com/token",
3180 "client-id",
3181 "client-secret",
3182 "agent-token-xyz",
3183 )
3184 .with_user_tokens(user_tokens);
3185
3186 let result = provider.auth_header_for("unknown-user", "tenant1").await;
3187 assert!(result.is_err());
3188 let err_msg = result.unwrap_err().to_string();
3189 assert!(
3190 err_msg.contains("unknown-user"),
3191 "error should mention the user_id: {err_msg}"
3192 );
3193 }
3194
3195 #[tokio::test]
3196 async fn mcp_tool_execute_catches_network_errors() {
3197 let transport = Arc::new(Transport::Http(HttpTransport {
3198 client: reqwest::Client::new(),
3199 endpoint: "http://127.0.0.1:1".to_string(), session_id: RwLock::new(None),
3201 next_id: AtomicU64::new(0),
3202 auth_header: None,
3203 }));
3204
3205 let tool = McpTool {
3206 transport,
3207 def: ToolDefinition {
3208 name: "test_tool".into(),
3209 description: "test".into(),
3210 input_schema: json!({"type": "object"}),
3211 },
3212 auth_resolver: None,
3213 };
3214
3215 let result = tool
3218 .execute(&crate::ExecutionContext::default(), json!({}))
3219 .await
3220 .unwrap();
3221 assert!(result.is_error);
3222 assert!(!result.content.is_empty());
3223 }
3224
3225 #[test]
3228 fn server_capabilities_parses_full() {
3229 let json = json!({
3230 "capabilities": {
3231 "resources": { "subscribe": true, "listChanged": true },
3232 "prompts": { "listChanged": false },
3233 "logging": {},
3234 "tools": { "listChanged": true }
3235 },
3236 "serverInfo": { "name": "test-server", "version": "1.0" }
3237 });
3238 let result: InitializeResult = serde_json::from_value(json).unwrap();
3239 assert!(result.capabilities.resources.is_some());
3240 let res = result.capabilities.resources.unwrap();
3241 assert!(res.subscribe);
3242 assert!(res.list_changed);
3243 assert!(result.capabilities.prompts.is_some());
3244 }
3245
3246 #[test]
3247 fn server_capabilities_parses_empty() {
3248 let json = json!({
3249 "capabilities": {},
3250 });
3251 let result: InitializeResult = serde_json::from_value(json).unwrap();
3252 assert!(result.capabilities.resources.is_none());
3253 assert!(result.capabilities.prompts.is_none());
3254 }
3255
3256 #[test]
3257 fn server_capabilities_defaults_on_missing() {
3258 let json = json!({});
3259 let result: InitializeResult = serde_json::from_value(json).unwrap();
3260 assert!(result.capabilities.resources.is_none());
3261 assert!(result.capabilities.prompts.is_none());
3262 }
3263
3264 #[test]
3265 fn server_capabilities_resources_only() {
3266 let json = json!({
3267 "capabilities": {
3268 "resources": {}
3269 }
3270 });
3271 let result: InitializeResult = serde_json::from_value(json).unwrap();
3272 assert!(result.capabilities.resources.is_some());
3273 let res = result.capabilities.resources.unwrap();
3274 assert!(!res.subscribe); assert!(!res.list_changed);
3276 assert!(result.capabilities.prompts.is_none());
3277 }
3278
3279 #[test]
3282 fn resource_def_serde_roundtrip() {
3283 let def = McpResourceDef {
3284 uri: "file:///README.md".into(),
3285 name: "README".into(),
3286 description: Some("Project readme".into()),
3287 mime_type: Some("text/markdown".into()),
3288 };
3289 let json = serde_json::to_value(&def).unwrap();
3290 assert_eq!(json["uri"], "file:///README.md");
3291 assert_eq!(json["name"], "README");
3292 let parsed: McpResourceDef = serde_json::from_value(json).unwrap();
3293 assert_eq!(parsed.uri, "file:///README.md");
3294 assert_eq!(parsed.mime_type.as_deref(), Some("text/markdown"));
3295 }
3296
3297 #[test]
3298 fn resource_def_minimal() {
3299 let json = json!({"uri": "test://x", "name": "x"});
3300 let def: McpResourceDef = serde_json::from_value(json).unwrap();
3301 assert_eq!(def.uri, "test://x");
3302 assert!(def.description.is_none());
3303 assert!(def.mime_type.is_none());
3304 }
3305
3306 #[test]
3307 fn resources_list_result_parsing() {
3308 let json = json!({
3309 "resources": [
3310 {
3311 "uri": "file:///config.toml",
3312 "name": "config",
3313 "description": "App configuration",
3314 "mimeType": "application/toml"
3315 },
3316 {
3317 "uri": "db://users/schema",
3318 "name": "users_schema"
3319 }
3320 ]
3321 });
3322 let result: McpResourcesListResult = serde_json::from_value(json).unwrap();
3323 assert_eq!(result.resources.len(), 2);
3324 assert_eq!(result.resources[0].uri, "file:///config.toml");
3325 assert_eq!(result.resources[0].name, "config");
3326 assert_eq!(
3327 result.resources[0].mime_type.as_deref(),
3328 Some("application/toml")
3329 );
3330 assert_eq!(result.resources[1].name, "users_schema");
3331 assert!(result.next_cursor.is_none());
3332 }
3333
3334 #[test]
3335 fn resources_list_with_cursor() {
3336 let json = json!({
3337 "resources": [{"uri": "a://1", "name": "one"}],
3338 "nextCursor": "page2"
3339 });
3340 let result: McpResourcesListResult = serde_json::from_value(json).unwrap();
3341 assert_eq!(result.resources.len(), 1);
3342 assert_eq!(result.next_cursor.as_deref(), Some("page2"));
3343 }
3344
3345 #[test]
3346 fn resource_content_parsing() {
3347 let json = json!({
3348 "uri": "file:///README.md",
3349 "mimeType": "text/markdown",
3350 "text": "# Hello World"
3351 });
3352 let content: McpResourceContent = serde_json::from_value(json).unwrap();
3353 assert_eq!(content.uri, "file:///README.md");
3354 assert_eq!(content.mime_type.as_deref(), Some("text/markdown"));
3355 assert_eq!(content.text.as_deref(), Some("# Hello World"));
3356 assert!(content.blob.is_none());
3357 }
3358
3359 #[test]
3360 fn resource_read_result_parsing() {
3361 let json = json!({
3362 "contents": [
3363 {"uri": "file:///a.txt", "text": "content A"},
3364 {"uri": "file:///b.txt", "text": "content B"}
3365 ]
3366 });
3367 let result: McpResourceReadResult = serde_json::from_value(json).unwrap();
3368 assert_eq!(result.contents.len(), 2);
3369 assert_eq!(result.contents[0].text.as_deref(), Some("content A"));
3370 }
3371
3372 #[test]
3375 fn prompt_def_serde_roundtrip() {
3376 let def = McpPromptDef {
3377 name: "summarize".into(),
3378 description: Some("Summarize text".into()),
3379 arguments: vec![McpPromptArgument {
3380 name: "text".into(),
3381 description: Some("Text to summarize".into()),
3382 required: true,
3383 }],
3384 };
3385 let json = serde_json::to_value(&def).unwrap();
3386 assert_eq!(json["name"], "summarize");
3387 let parsed: McpPromptDef = serde_json::from_value(json).unwrap();
3388 assert_eq!(parsed.arguments.len(), 1);
3389 assert!(parsed.arguments[0].required);
3390 }
3391
3392 #[test]
3393 fn prompt_def_minimal() {
3394 let json = json!({"name": "greet"});
3395 let def: McpPromptDef = serde_json::from_value(json).unwrap();
3396 assert_eq!(def.name, "greet");
3397 assert!(def.description.is_none());
3398 assert!(def.arguments.is_empty());
3399 }
3400
3401 #[test]
3402 fn prompts_list_result_parsing() {
3403 let json = json!({
3404 "prompts": [
3405 {
3406 "name": "code_review",
3407 "description": "Review code for issues",
3408 "arguments": [
3409 {"name": "code", "description": "Code to review", "required": true},
3410 {"name": "language", "description": "Programming language", "required": false}
3411 ]
3412 }
3413 ]
3414 });
3415 let result: McpPromptsListResult = serde_json::from_value(json).unwrap();
3416 assert_eq!(result.prompts.len(), 1);
3417 assert_eq!(result.prompts[0].name, "code_review");
3418 assert_eq!(result.prompts[0].arguments.len(), 2);
3419 assert!(result.prompts[0].arguments[0].required);
3420 assert!(!result.prompts[0].arguments[1].required);
3421 }
3422
3423 #[test]
3424 fn prompt_get_result_parsing() {
3425 let json = json!({
3426 "description": "A helpful prompt",
3427 "messages": [
3428 {
3429 "role": "user",
3430 "content": {"type": "text", "text": "Please help me with this code"}
3431 },
3432 {
3433 "role": "assistant",
3434 "content": {"type": "text", "text": "I'd be happy to help!"}
3435 }
3436 ]
3437 });
3438 let result: McpPromptGetResult = serde_json::from_value(json).unwrap();
3439 assert_eq!(result.messages.len(), 2);
3440 assert_eq!(result.messages[0].role, "user");
3441 assert_eq!(
3442 result.messages[0].content.text.as_deref(),
3443 Some("Please help me with this code")
3444 );
3445 assert_eq!(result.messages[1].role, "assistant");
3446 }
3447
3448 #[test]
3451 fn sanitize_tool_name_alphanumeric() {
3452 assert_eq!(sanitize_tool_name("hello_world"), "hello_world");
3453 assert_eq!(sanitize_tool_name("test123"), "test123");
3454 }
3455
3456 #[test]
3457 fn sanitize_tool_name_special_chars() {
3458 assert_eq!(sanitize_tool_name("my-resource"), "my_resource");
3459 assert_eq!(sanitize_tool_name("path/to/thing"), "path_to_thing");
3460 assert_eq!(sanitize_tool_name("file.txt"), "file_txt");
3461 assert_eq!(sanitize_tool_name("a b c"), "a_b_c");
3462 }
3463
3464 #[test]
3467 fn resource_tool_definition() {
3468 let transport = Arc::new(Transport::Http(HttpTransport {
3469 client: reqwest::Client::new(),
3470 endpoint: "http://unused".to_string(),
3471 session_id: RwLock::new(None),
3472 next_id: AtomicU64::new(0),
3473 auth_header: None,
3474 }));
3475
3476 let tool = McpResourceTool {
3477 transport,
3478 resource: McpResourceDef {
3479 uri: "file:///README.md".into(),
3480 name: "readme".into(),
3481 description: Some("Project readme".into()),
3482 mime_type: None,
3483 },
3484 tool_name: "mcp_resource_readme".into(),
3485 auth_resolver: None,
3486 };
3487
3488 let def = tool.definition();
3489 assert_eq!(def.name, "mcp_resource_readme");
3490 assert_eq!(def.description, "Project readme");
3491 assert_eq!(
3492 def.input_schema,
3493 json!({"type": "object", "properties": {}})
3494 );
3495 }
3496
3497 #[test]
3498 fn resource_tool_definition_default_description() {
3499 let transport = Arc::new(Transport::Http(HttpTransport {
3500 client: reqwest::Client::new(),
3501 endpoint: "http://unused".to_string(),
3502 session_id: RwLock::new(None),
3503 next_id: AtomicU64::new(0),
3504 auth_header: None,
3505 }));
3506
3507 let tool = McpResourceTool {
3508 transport,
3509 resource: McpResourceDef {
3510 uri: "db://users".into(),
3511 name: "users".into(),
3512 description: None,
3513 mime_type: None,
3514 },
3515 tool_name: "mcp_resource_users".into(),
3516 auth_resolver: None,
3517 };
3518
3519 let def = tool.definition();
3520 assert!(def.description.contains("db://users"));
3521 }
3522
3523 #[test]
3526 fn prompt_tool_definition_with_args() {
3527 let transport = Arc::new(Transport::Http(HttpTransport {
3528 client: reqwest::Client::new(),
3529 endpoint: "http://unused".to_string(),
3530 session_id: RwLock::new(None),
3531 next_id: AtomicU64::new(0),
3532 auth_header: None,
3533 }));
3534
3535 let tool = McpPromptTool {
3536 transport,
3537 prompt: McpPromptDef {
3538 name: "review".into(),
3539 description: Some("Code review".into()),
3540 arguments: vec![
3541 McpPromptArgument {
3542 name: "code".into(),
3543 description: Some("Code to review".into()),
3544 required: true,
3545 },
3546 McpPromptArgument {
3547 name: "language".into(),
3548 description: None,
3549 required: false,
3550 },
3551 ],
3552 },
3553 tool_name: "mcp_prompt_review".into(),
3554 auth_resolver: None,
3555 };
3556
3557 let def = tool.definition();
3558 assert_eq!(def.name, "mcp_prompt_review");
3559 assert_eq!(def.description, "Code review");
3560 let schema = &def.input_schema;
3561 assert!(schema["properties"]["code"].is_object());
3562 assert_eq!(
3563 schema["properties"]["code"]["description"],
3564 "Code to review"
3565 );
3566 assert_eq!(schema["required"], json!(["code"]));
3567 assert!(
3569 !schema["required"]
3570 .as_array()
3571 .unwrap()
3572 .contains(&json!("language"))
3573 );
3574 }
3575
3576 #[test]
3577 fn prompt_tool_definition_no_args() {
3578 let transport = Arc::new(Transport::Http(HttpTransport {
3579 client: reqwest::Client::new(),
3580 endpoint: "http://unused".to_string(),
3581 session_id: RwLock::new(None),
3582 next_id: AtomicU64::new(0),
3583 auth_header: None,
3584 }));
3585
3586 let tool = McpPromptTool {
3587 transport,
3588 prompt: McpPromptDef {
3589 name: "greet".into(),
3590 description: None,
3591 arguments: vec![],
3592 },
3593 tool_name: "mcp_prompt_greet".into(),
3594 auth_resolver: None,
3595 };
3596
3597 let def = tool.definition();
3598 assert_eq!(def.name, "mcp_prompt_greet");
3599 assert!(def.description.contains("greet"));
3600 assert!(def.input_schema.get("required").is_none());
3602 }
3603
3604 #[test]
3607 fn into_resource_tools_creates_correct_names() {
3608 let transport = Arc::new(Transport::Http(HttpTransport {
3609 client: reqwest::Client::new(),
3610 endpoint: "http://unused".to_string(),
3611 session_id: RwLock::new(None),
3612 next_id: AtomicU64::new(0),
3613 auth_header: None,
3614 }));
3615
3616 let client = McpClient {
3617 transport,
3618 tools: vec![],
3619 resources: vec![
3620 McpResourceDef {
3621 uri: "file:///a.txt".into(),
3622 name: "readme-file".into(),
3623 description: None,
3624 mime_type: None,
3625 },
3626 McpResourceDef {
3627 uri: "db://schema".into(),
3628 name: "db schema".into(),
3629 description: Some("Database schema".into()),
3630 mime_type: None,
3631 },
3632 ],
3633 prompts: vec![],
3634 capabilities: ServerCapabilities::default(),
3635 sampling_handler: None,
3636 roots: Vec::new(),
3637 };
3638
3639 let tools = client.into_resource_tools();
3640 assert_eq!(tools.len(), 2);
3641 assert_eq!(tools[0].definition().name, "mcp_resource_readme_file");
3642 assert_eq!(tools[1].definition().name, "mcp_resource_db_schema");
3643 assert_eq!(tools[1].definition().description, "Database schema");
3644 }
3645
3646 #[test]
3647 fn into_prompt_tools_creates_correct_names() {
3648 let transport = Arc::new(Transport::Http(HttpTransport {
3649 client: reqwest::Client::new(),
3650 endpoint: "http://unused".to_string(),
3651 session_id: RwLock::new(None),
3652 next_id: AtomicU64::new(0),
3653 auth_header: None,
3654 }));
3655
3656 let client = McpClient {
3657 transport,
3658 tools: vec![],
3659 resources: vec![],
3660 prompts: vec![McpPromptDef {
3661 name: "code-review".into(),
3662 description: Some("Review code".into()),
3663 arguments: vec![],
3664 }],
3665 capabilities: ServerCapabilities::default(),
3666 sampling_handler: None,
3667 roots: Vec::new(),
3668 };
3669
3670 let tools = client.into_prompt_tools();
3671 assert_eq!(tools.len(), 1);
3672 assert_eq!(tools[0].definition().name, "mcp_prompt_code_review");
3673 }
3674
3675 #[test]
3676 fn into_all_tools_combines_everything() {
3677 let transport = Arc::new(Transport::Http(HttpTransport {
3678 client: reqwest::Client::new(),
3679 endpoint: "http://unused".to_string(),
3680 session_id: RwLock::new(None),
3681 next_id: AtomicU64::new(0),
3682 auth_header: None,
3683 }));
3684
3685 let client = McpClient {
3686 transport,
3687 tools: vec![McpToolDef {
3688 name: "read_file".into(),
3689 description: Some("Read a file".into()),
3690 input_schema: Some(json!({"type": "object"})),
3691 }],
3692 resources: vec![McpResourceDef {
3693 uri: "file:///a.txt".into(),
3694 name: "readme".into(),
3695 description: None,
3696 mime_type: None,
3697 }],
3698 prompts: vec![McpPromptDef {
3699 name: "greet".into(),
3700 description: None,
3701 arguments: vec![],
3702 }],
3703 capabilities: ServerCapabilities::default(),
3704 sampling_handler: None,
3705 roots: Vec::new(),
3706 };
3707
3708 let all = client.into_all_tools();
3709 assert_eq!(all.len(), 3);
3710 let names: Vec<String> = all.iter().map(|t| t.definition().name).collect();
3711 assert!(names.contains(&"read_file".to_string()));
3712 assert!(names.contains(&"mcp_resource_readme".to_string()));
3713 assert!(names.contains(&"mcp_prompt_greet".to_string()));
3714 }
3715
3716 #[test]
3717 fn supports_resource_subscribe_false_by_default() {
3718 let transport = Arc::new(Transport::Http(HttpTransport {
3719 client: reqwest::Client::new(),
3720 endpoint: "http://unused".to_string(),
3721 session_id: RwLock::new(None),
3722 next_id: AtomicU64::new(0),
3723 auth_header: None,
3724 }));
3725 let client = McpClient {
3726 transport,
3727 tools: vec![],
3728 resources: vec![],
3729 prompts: vec![],
3730 capabilities: ServerCapabilities::default(),
3731 sampling_handler: None,
3732 roots: Vec::new(),
3733 };
3734 assert!(!client.supports_resource_subscribe());
3735 }
3736
3737 #[test]
3738 fn supports_resource_subscribe_when_advertised() {
3739 let transport = Arc::new(Transport::Http(HttpTransport {
3740 client: reqwest::Client::new(),
3741 endpoint: "http://unused".to_string(),
3742 session_id: RwLock::new(None),
3743 next_id: AtomicU64::new(0),
3744 auth_header: None,
3745 }));
3746 let client = McpClient {
3747 transport,
3748 tools: vec![],
3749 resources: vec![],
3750 prompts: vec![],
3751 capabilities: ServerCapabilities {
3752 resources: Some(ResourcesCapability {
3753 subscribe: true,
3754 list_changed: false,
3755 }),
3756 ..Default::default()
3757 },
3758 sampling_handler: None,
3759 roots: Vec::new(),
3760 };
3761 assert!(client.supports_resource_subscribe());
3762 }
3763
3764 #[test]
3767 fn sampling_request_parsing() {
3768 let json = json!({
3769 "messages": [
3770 {
3771 "role": "user",
3772 "content": {"type": "text", "text": "What is 2+2?"}
3773 }
3774 ],
3775 "modelPreferences": {
3776 "hints": [{"name": "claude-sonnet-4-6-20250610"}]
3777 },
3778 "systemPrompt": "You are a math helper",
3779 "maxTokens": 100
3780 });
3781 let req: SamplingRequest = serde_json::from_value(json).unwrap();
3782 assert_eq!(req.messages.len(), 1);
3783 assert_eq!(req.messages[0].role, "user");
3784 assert_eq!(
3785 req.messages[0].content.text.as_deref(),
3786 Some("What is 2+2?")
3787 );
3788 assert_eq!(req.system_prompt.as_deref(), Some("You are a math helper"));
3789 assert_eq!(req.max_tokens, Some(100));
3790 let hints = &req.model_preferences.unwrap().hints;
3791 assert_eq!(hints[0].name.as_deref(), Some("claude-sonnet-4-6-20250610"));
3792 }
3793
3794 #[test]
3795 fn sampling_request_minimal() {
3796 let json = json!({
3797 "messages": [{"role": "user", "content": {"type": "text", "text": "hi"}}]
3798 });
3799 let req: SamplingRequest = serde_json::from_value(json).unwrap();
3800 assert_eq!(req.messages.len(), 1);
3801 assert!(req.model_preferences.is_none());
3802 assert!(req.system_prompt.is_none());
3803 assert!(req.max_tokens.is_none());
3804 }
3805
3806 #[test]
3807 fn sampling_response_serialization() {
3808 let resp = SamplingResponse {
3809 role: "assistant".into(),
3810 content: SamplingContent {
3811 content_type: "text".into(),
3812 text: Some("4".into()),
3813 },
3814 model: "claude-sonnet-4-6-20250610".into(),
3815 };
3816 let json = serde_json::to_value(&resp).unwrap();
3817 assert_eq!(json["role"], "assistant");
3818 assert_eq!(json["content"]["type"], "text");
3819 assert_eq!(json["content"]["text"], "4");
3820 assert_eq!(json["model"], "claude-sonnet-4-6-20250610");
3821 }
3822
3823 #[test]
3824 fn sampling_message_serde_roundtrip() {
3825 let msg = SamplingMessage {
3826 role: "user".into(),
3827 content: SamplingContent {
3828 content_type: "text".into(),
3829 text: Some("hello".into()),
3830 },
3831 };
3832 let json = serde_json::to_value(&msg).unwrap();
3833 let parsed: SamplingMessage = serde_json::from_value(json).unwrap();
3834 assert_eq!(parsed.role, "user");
3835 assert_eq!(parsed.content.text.as_deref(), Some("hello"));
3836 }
3837
3838 #[test]
3839 fn with_sampling_sets_handler() {
3840 let transport = Arc::new(Transport::Http(HttpTransport {
3841 client: reqwest::Client::new(),
3842 endpoint: "http://unused".to_string(),
3843 session_id: RwLock::new(None),
3844 next_id: AtomicU64::new(0),
3845 auth_header: None,
3846 }));
3847 let client = McpClient {
3848 transport,
3849 tools: vec![],
3850 resources: vec![],
3851 prompts: vec![],
3852 capabilities: ServerCapabilities::default(),
3853 sampling_handler: None,
3854 roots: Vec::new(),
3855 };
3856 assert!(client.sampling_handler.is_none());
3857
3858 let handler: SamplingHandler =
3859 Arc::new(|_req| Box::pin(async move { Ok(("response".into(), "model".into())) }));
3860 let client = client.with_sampling(handler);
3861 assert!(client.sampling_handler.is_some());
3862 }
3863
3864 #[test]
3867 fn handle_log_notification_info() {
3868 let value = json!({
3870 "jsonrpc": "2.0",
3871 "method": "notifications/message",
3872 "params": {"level": "info", "logger": "test-server", "data": "Server started"}
3873 });
3874 handle_log_notification(&value);
3875 }
3876
3877 #[test]
3878 fn handle_log_notification_error() {
3879 let value = json!({
3880 "jsonrpc": "2.0",
3881 "method": "notifications/message",
3882 "params": {"level": "error", "data": "Something went wrong"}
3883 });
3884 handle_log_notification(&value);
3885 }
3886
3887 #[test]
3888 fn handle_log_notification_missing_params() {
3889 let value = json!({"jsonrpc": "2.0", "method": "notifications/message"});
3890 handle_log_notification(&value); }
3892
3893 #[test]
3894 fn find_rpc_response_skips_log_notifications() {
3895 let events = vec![
3896 r#"{"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","data":"log"}}"#.to_string(),
3897 r#"{"jsonrpc":"2.0","result":{"ok":true},"id":1}"#.to_string(),
3898 ];
3899 let result = find_rpc_response(&events, 1).unwrap();
3900 assert!(result.contains("\"id\":1"));
3901 }
3902
3903 #[test]
3906 fn mcp_root_serde_roundtrip() {
3907 let root = McpRoot {
3908 uri: "file:///workspace/project".into(),
3909 name: Some("project".into()),
3910 };
3911 let json = serde_json::to_value(&root).unwrap();
3912 assert_eq!(json["uri"], "file:///workspace/project");
3913 assert_eq!(json["name"], "project");
3914 let parsed: McpRoot = serde_json::from_value(json).unwrap();
3915 assert_eq!(parsed.uri, "file:///workspace/project");
3916 }
3917
3918 #[test]
3919 fn mcp_root_minimal() {
3920 let json = json!({"uri": "file:///tmp"});
3921 let root: McpRoot = serde_json::from_value(json).unwrap();
3922 assert_eq!(root.uri, "file:///tmp");
3923 assert!(root.name.is_none());
3924 }
3925
3926 #[test]
3927 fn mcp_root_name_omitted_when_none() {
3928 let root = McpRoot {
3929 uri: "file:///x".into(),
3930 name: None,
3931 };
3932 let json = serde_json::to_string(&root).unwrap();
3933 assert!(!json.contains("name"));
3934 }
3935
3936 #[test]
3937 fn with_roots_sets_roots() {
3938 let transport = Arc::new(Transport::Http(HttpTransport {
3939 client: reqwest::Client::new(),
3940 endpoint: "http://unused".to_string(),
3941 session_id: RwLock::new(None),
3942 next_id: AtomicU64::new(0),
3943 auth_header: None,
3944 }));
3945 let client = McpClient {
3946 transport,
3947 tools: vec![],
3948 resources: vec![],
3949 prompts: vec![],
3950 capabilities: ServerCapabilities::default(),
3951 sampling_handler: None,
3952 roots: Vec::new(),
3953 };
3954 assert!(client.roots().is_empty());
3955
3956 let client = client.with_roots(vec![McpRoot {
3957 uri: "file:///workspace".into(),
3958 name: Some("workspace".into()),
3959 }]);
3960 assert_eq!(client.roots().len(), 1);
3961 assert_eq!(client.roots()[0].uri, "file:///workspace");
3962 }
3963
3964 #[tokio::test]
3965 async fn read_stdio_response_forwards_log_notifications() {
3966 let (mut tx, rx) = tokio::io::duplex(4096);
3967 let mut reader = tokio::io::BufReader::new(rx);
3968
3969 tokio::spawn(async move {
3970 tx.write_all(b"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/message\",\"params\":{\"level\":\"info\",\"data\":\"test log\"}}\n")
3972 .await
3973 .unwrap();
3974 tx.write_all(b"{\"jsonrpc\":\"2.0\",\"result\":{\"ok\":true},\"id\":1}\n")
3975 .await
3976 .unwrap();
3977 });
3978
3979 let response = read_stdio_response(&mut reader, 1).await.unwrap();
3980 assert!(response.contains("\"id\":1"));
3981 assert!(response.contains("\"ok\":true"));
3982 }
3983
3984 #[tokio::test]
3987 async fn static_auth_resolver_returns_header() {
3988 let resolver = StaticAuthResolver(Some("Bearer xyz".into()));
3989 let result = resolver.resolve().await.unwrap();
3990 assert_eq!(result, Some("Bearer xyz".to_string()));
3991 }
3992
3993 #[tokio::test]
3994 async fn static_auth_resolver_returns_none() {
3995 let resolver = StaticAuthResolver(None);
3996 let result = resolver.resolve().await.unwrap();
3997 assert_eq!(result, None);
3998 }
3999
4000 #[tokio::test]
4001 async fn dynamic_auth_resolver_calls_provider() {
4002 let provider = Arc::new(StaticAuthProvider::new(Some("Bearer dynamic".into())));
4003 let resolver = DynamicAuthResolver::new(provider, "user1", "tenant1");
4004 let result = resolver.resolve().await.unwrap();
4005 assert_eq!(result, Some("Bearer dynamic".to_string()));
4006 }
4007
4008 #[tokio::test]
4009 async fn dynamic_auth_resolver_with_resource_and_scopes() {
4010 let provider = Arc::new(StaticAuthProvider::new(Some("Bearer scoped".into())));
4011 let resolver = DynamicAuthResolver::new(provider, "user1", "tenant1")
4012 .with_resource(Some("https://gmail.googleapis.com".into()))
4013 .with_scopes(Some(vec!["gmail.readonly".into()]));
4014 let result = resolver.resolve().await.unwrap();
4016 assert_eq!(result, Some("Bearer scoped".to_string()));
4017 }
4018
4019 #[tokio::test]
4020 async fn auth_header_for_resource_default_delegates() {
4021 let provider = StaticAuthProvider::new(Some("Bearer base".into()));
4022 let result = provider
4023 .auth_header_for_resource(
4024 "user1",
4025 "tenant1",
4026 Some("https://resource.example.com"),
4027 Some(&["scope1".into()]),
4028 )
4029 .await
4030 .unwrap();
4031 assert_eq!(result, Some("Bearer base".to_string()));
4033 }
4034
4035 #[tokio::test]
4038 async fn mcp_tool_with_resolver_injects_auth() {
4039 let transport = Arc::new(Transport::Http(HttpTransport {
4041 client: reqwest::Client::new(),
4042 endpoint: "http://127.0.0.1:1".to_string(),
4043 session_id: RwLock::new(None),
4044 next_id: AtomicU64::new(0),
4045 auth_header: None,
4046 }));
4047
4048 let resolver: Arc<dyn AuthResolver> =
4049 Arc::new(StaticAuthResolver(Some("Bearer user-token".into())));
4050 let tool = McpTool {
4051 transport,
4052 def: ToolDefinition {
4053 name: "test_tool".into(),
4054 description: "test".into(),
4055 input_schema: json!({"type": "object"}),
4056 },
4057 auth_resolver: Some(resolver),
4058 };
4059
4060 let result = tool
4062 .execute(&crate::ExecutionContext::default(), json!({}))
4063 .await
4064 .unwrap();
4065 assert!(result.is_error);
4066 }
4067
4068 #[tokio::test]
4069 async fn mcp_tool_without_resolver_uses_transport_default() {
4070 let transport = Arc::new(Transport::Http(HttpTransport {
4071 client: reqwest::Client::new(),
4072 endpoint: "http://127.0.0.1:1".to_string(),
4073 session_id: RwLock::new(None),
4074 next_id: AtomicU64::new(0),
4075 auth_header: Some("Bearer static".into()),
4076 }));
4077
4078 let tool = McpTool {
4079 transport,
4080 def: ToolDefinition {
4081 name: "test_tool".into(),
4082 description: "test".into(),
4083 input_schema: json!({"type": "object"}),
4084 },
4085 auth_resolver: None,
4086 };
4087
4088 let result = tool
4090 .execute(&crate::ExecutionContext::default(), json!({}))
4091 .await
4092 .unwrap();
4093 assert!(result.is_error);
4094 }
4095
4096 #[test]
4099 fn transport_pool_new_is_empty() {
4100 let pool = McpTransportPool::new();
4101 assert!(!pool.contains("http://example.com/mcp"));
4102 }
4103
4104 #[test]
4105 fn transport_pool_tools_for_user_returns_none_for_unknown_url() {
4106 let pool = McpTransportPool::new();
4107 let resolver: Arc<dyn AuthResolver> = Arc::new(StaticAuthResolver(None));
4108 let result = pool
4109 .tools_for_user("http://unknown.example.com/mcp", resolver)
4110 .unwrap();
4111 assert!(result.is_none());
4112 }
4113
4114 #[test]
4115 fn transport_pool_default_trait() {
4116 let pool = McpTransportPool::default();
4117 assert!(!pool.contains("http://example.com/mcp"));
4118 }
4119
4120 #[test]
4123 fn into_tools_with_auth_stamps_resolver() {
4124 let transport = Arc::new(Transport::Http(HttpTransport {
4125 client: reqwest::Client::new(),
4126 endpoint: "http://unused".to_string(),
4127 session_id: RwLock::new(None),
4128 next_id: AtomicU64::new(0),
4129 auth_header: None,
4130 }));
4131
4132 let client = McpClient {
4133 transport,
4134 tools: vec![McpToolDef {
4135 name: "read_file".into(),
4136 description: Some("Read a file".into()),
4137 input_schema: Some(json!({"type": "object"})),
4138 }],
4139 resources: vec![],
4140 prompts: vec![],
4141 capabilities: ServerCapabilities::default(),
4142 sampling_handler: None,
4143 roots: Vec::new(),
4144 };
4145
4146 let resolver: Arc<dyn AuthResolver> =
4147 Arc::new(StaticAuthResolver(Some("Bearer user".into())));
4148 let tools = client.into_tools_with_auth(resolver);
4149 assert_eq!(tools.len(), 1);
4150 assert_eq!(tools[0].definition().name, "read_file");
4151 }
4152
4153 #[test]
4156 fn static_auth_provider_always_has_credentials() {
4157 let provider = StaticAuthProvider::new(Some("Bearer x".into()));
4158 assert!(provider.has_credentials("u", "t"));
4159 let provider = StaticAuthProvider::new(None);
4160 assert!(provider.has_credentials("u", "t"));
4161 }
4162
4163 #[test]
4164 fn token_exchange_has_credentials_checks_user_tokens() {
4165 let user_tokens = Arc::new(std::sync::RwLock::new(HashMap::<String, String>::new()));
4166 let provider = TokenExchangeAuthProvider::new(
4167 "https://auth.example.com/token",
4168 "client_id",
4169 "client_secret",
4170 "agent_token",
4171 )
4172 .with_user_tokens(Arc::clone(&user_tokens));
4173
4174 assert!(!provider.has_credentials("alice", "acme"));
4176
4177 user_tokens
4179 .write()
4180 .unwrap()
4181 .insert("acme:alice".to_string(), "jwt-alice".to_string());
4182 assert!(provider.has_credentials("alice", "acme"));
4183
4184 assert!(!provider.has_credentials("bob", "acme"));
4186 }
4187
4188 #[tokio::test]
4191 async fn direct_auth_provider_auth_header_for_returns_none() {
4192 let mut tokens = HashMap::new();
4193 tokens.insert("http://mcp.example.com".to_string(), "tok_abc".to_string());
4194 let provider = DirectAuthProvider::new(tokens);
4195 let result = provider.auth_header_for("user1", "tenant1").await.unwrap();
4196 assert!(result.is_none());
4197 }
4198
4199 #[tokio::test]
4200 async fn direct_auth_provider_returns_token_for_known_url() {
4201 let mut tokens = HashMap::new();
4202 tokens.insert("http://mcp.example.com".to_string(), "tok_abc".to_string());
4203 let provider = DirectAuthProvider::new(tokens);
4204 let result = provider
4205 .auth_header_for_resource("u", "t", Some("http://mcp.example.com"), None)
4206 .await
4207 .unwrap();
4208 assert_eq!(result.as_deref(), Some("Bearer tok_abc"));
4209 }
4210
4211 #[tokio::test]
4212 async fn direct_auth_provider_returns_none_for_unknown_url() {
4213 let mut tokens = HashMap::new();
4214 tokens.insert("http://mcp.example.com".to_string(), "tok_abc".to_string());
4215 let provider = DirectAuthProvider::new(tokens);
4216 let result = provider
4217 .auth_header_for_resource("u", "t", Some("http://other.example.com"), None)
4218 .await
4219 .unwrap();
4220 assert!(result.is_none());
4221 }
4222
4223 #[tokio::test]
4224 async fn direct_auth_provider_returns_none_for_no_resource() {
4225 let mut tokens = HashMap::new();
4226 tokens.insert("http://mcp.example.com".to_string(), "tok_abc".to_string());
4227 let provider = DirectAuthProvider::new(tokens);
4228 let result = provider
4229 .auth_header_for_resource("u", "t", None, None)
4230 .await
4231 .unwrap();
4232 assert!(result.is_none());
4233 }
4234
4235 #[test]
4236 fn direct_auth_provider_has_credentials_non_empty() {
4237 let mut tokens = HashMap::new();
4238 tokens.insert("http://mcp.example.com".to_string(), "tok_abc".to_string());
4239 let provider = DirectAuthProvider::new(tokens);
4240 assert!(provider.has_credentials("u", "t"));
4241 }
4242
4243 #[test]
4244 fn direct_auth_provider_has_credentials_empty() {
4245 let provider = DirectAuthProvider::new(HashMap::new());
4246 assert!(!provider.has_credentials("u", "t"));
4247 }
4248
4249 #[test]
4250 fn into_all_tools_with_auth_stamps_resolver() {
4251 let transport = Arc::new(Transport::Http(HttpTransport {
4252 client: reqwest::Client::new(),
4253 endpoint: "http://unused".to_string(),
4254 session_id: RwLock::new(None),
4255 next_id: AtomicU64::new(0),
4256 auth_header: None,
4257 }));
4258
4259 let client = McpClient {
4260 transport,
4261 tools: vec![McpToolDef {
4262 name: "tool1".into(),
4263 description: None,
4264 input_schema: None,
4265 }],
4266 resources: vec![McpResourceDef {
4267 uri: "file:///a.txt".into(),
4268 name: "readme".into(),
4269 description: None,
4270 mime_type: None,
4271 }],
4272 prompts: vec![McpPromptDef {
4273 name: "greet".into(),
4274 description: None,
4275 arguments: vec![],
4276 }],
4277 capabilities: ServerCapabilities::default(),
4278 sampling_handler: None,
4279 roots: Vec::new(),
4280 };
4281
4282 let resolver: Arc<dyn AuthResolver> =
4283 Arc::new(StaticAuthResolver(Some("Bearer user".into())));
4284 let all = client.into_all_tools_with_auth(resolver);
4285 assert_eq!(all.len(), 3);
4286 let names: Vec<String> = all.iter().map(|t| t.definition().name).collect();
4287 assert!(names.contains(&"tool1".to_string()));
4288 assert!(names.contains(&"mcp_resource_readme".to_string()));
4289 assert!(names.contains(&"mcp_prompt_greet".to_string()));
4290 }
4291
4292 #[tokio::test]
4298 async fn connect_http_rejects_loopback_url() {
4299 let result = McpClient::connect_with_auth("http://127.0.0.1/", "Bearer secret").await;
4300 assert!(result.is_err(), "loopback URL must be rejected pre-connect");
4301 let msg = result.err().expect("must be Err").to_string();
4302 assert!(
4303 msg.contains("private")
4304 || msg.contains("loopback")
4305 || msg.contains("refused")
4306 || msg.contains("/127."),
4307 "error should mention SSRF rejection; got: {msg}"
4308 );
4309 }
4310
4311 #[tokio::test]
4315 async fn connect_http_rejects_file_scheme() {
4316 let result = McpClient::connect("file:///etc/passwd").await;
4317 assert!(result.is_err(), "file:// scheme must be rejected");
4318 let msg = result.err().expect("must be Err").to_string();
4319 assert!(
4320 msg.contains("scheme") || msg.contains("file"),
4321 "error should mention scheme; got: {msg}"
4322 );
4323 }
4324
4325 #[tokio::test]
4328 async fn connect_http_rejects_aws_metadata_url() {
4329 let result = McpClient::connect("http://169.254.169.254/").await;
4330 assert!(result.is_err(), "metadata URL must be rejected pre-connect");
4331 }
4332}