1use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use std::collections::{BTreeMap, HashMap};
15
16use crate::typed_id::McpServerId;
17
18#[cfg(feature = "openapi")]
19use utoipa::ToSchema;
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23#[cfg_attr(feature = "openapi", derive(ToSchema))]
24#[cfg_attr(feature = "openapi", schema(example = "http"))]
25#[serde(rename_all = "lowercase")]
26pub enum McpServerTransportType {
27 Http,
29 Stdio,
33}
34
35impl McpServerTransportType {
36 pub fn is_local(&self) -> bool {
39 matches!(self, McpServerTransportType::Stdio)
40 }
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
45#[cfg_attr(feature = "openapi", derive(ToSchema))]
46#[cfg_attr(feature = "openapi", schema(example = "api_key"))]
47#[serde(rename_all = "snake_case")]
48pub enum McpServerAuthMode {
49 #[default]
51 None,
52 ApiKey,
54 OAuth,
56}
57
58impl std::fmt::Display for McpServerAuthMode {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 match self {
61 McpServerAuthMode::None => write!(f, "none"),
62 McpServerAuthMode::ApiKey => write!(f, "api_key"),
63 McpServerAuthMode::OAuth => write!(f, "oauth"),
64 }
65 }
66}
67
68impl From<&str> for McpServerAuthMode {
69 fn from(s: &str) -> Self {
70 match s {
71 "api_key" => McpServerAuthMode::ApiKey,
72 "oauth" => McpServerAuthMode::OAuth,
73 _ => McpServerAuthMode::None,
74 }
75 }
76}
77
78impl McpServerAuthMode {
79 pub fn is_none(&self) -> bool {
80 matches!(self, McpServerAuthMode::None)
81 }
82}
83
84pub const MCP_PROTOCOL_VERSION_LEGACY: &str = "2025-03-26";
104pub const MCP_PROTOCOL_VERSION_STABLE: &str = "2025-06-18";
106pub const MCP_PROTOCOL_VERSION_RC: &str = "2026-07-28";
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
118#[cfg_attr(feature = "openapi", derive(ToSchema))]
119#[cfg_attr(feature = "openapi", schema(example = "auto"))]
120#[serde(rename_all = "snake_case")]
121pub enum McpProtocolMode {
122 #[default]
124 Auto,
125 Legacy,
127 Stable,
129 Rc,
132}
133
134impl McpProtocolMode {
135 pub fn is_auto(&self) -> bool {
138 matches!(self, McpProtocolMode::Auto)
139 }
140
141 pub fn pinned_version(&self) -> Option<&'static str> {
144 match self {
145 McpProtocolMode::Auto => None,
146 McpProtocolMode::Legacy => Some(MCP_PROTOCOL_VERSION_LEGACY),
147 McpProtocolMode::Stable => Some(MCP_PROTOCOL_VERSION_STABLE),
148 McpProtocolMode::Rc => Some(MCP_PROTOCOL_VERSION_RC),
149 }
150 }
151
152 pub fn pinned_stateful(&self) -> Option<bool> {
155 match self {
156 McpProtocolMode::Auto => None,
157 McpProtocolMode::Legacy | McpProtocolMode::Stable => Some(true),
158 McpProtocolMode::Rc => Some(false),
159 }
160 }
161}
162
163impl std::fmt::Display for McpProtocolMode {
164 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165 match self {
166 McpProtocolMode::Auto => write!(f, "auto"),
167 McpProtocolMode::Legacy => write!(f, "legacy"),
168 McpProtocolMode::Stable => write!(f, "stable"),
169 McpProtocolMode::Rc => write!(f, "rc"),
170 }
171 }
172}
173
174impl From<&str> for McpProtocolMode {
175 fn from(s: &str) -> Self {
176 match s {
177 "legacy" => McpProtocolMode::Legacy,
178 "stable" => McpProtocolMode::Stable,
179 "rc" => McpProtocolMode::Rc,
180 _ => McpProtocolMode::Auto,
181 }
182 }
183}
184
185pub fn normalize_mcp_error_code(code: i64) -> i64 {
192 match code {
193 -32002 => -32602,
194 other => other,
195 }
196}
197
198impl std::fmt::Display for McpServerTransportType {
199 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200 match self {
201 McpServerTransportType::Http => write!(f, "http"),
202 McpServerTransportType::Stdio => write!(f, "stdio"),
203 }
204 }
205}
206
207impl From<&str> for McpServerTransportType {
208 fn from(s: &str) -> Self {
209 match s {
210 "stdio" => McpServerTransportType::Stdio,
211 _ => McpServerTransportType::Http,
213 }
214 }
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
223#[cfg_attr(feature = "openapi", derive(ToSchema))]
224#[cfg_attr(feature = "openapi", schema(example = "active"))]
225#[serde(rename_all = "lowercase")]
226pub enum McpServerStatus {
227 Active,
229 Disabled,
231 Archived,
233 Deleted,
235}
236
237impl std::fmt::Display for McpServerStatus {
238 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239 match self {
240 McpServerStatus::Active => write!(f, "active"),
241 McpServerStatus::Disabled => write!(f, "disabled"),
242 McpServerStatus::Archived => write!(f, "archived"),
243 McpServerStatus::Deleted => write!(f, "deleted"),
244 }
245 }
246}
247
248impl From<&str> for McpServerStatus {
249 fn from(s: &str) -> Self {
250 match s {
251 "disabled" => McpServerStatus::Disabled,
252 "archived" => McpServerStatus::Archived,
253 "deleted" => McpServerStatus::Deleted,
254 _ => McpServerStatus::Active,
255 }
256 }
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
262#[cfg_attr(feature = "openapi", derive(ToSchema))]
263pub struct McpServer {
264 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "mcp_01933b5a00007000800000000000001"))]
266 pub id: McpServerId,
267 #[cfg_attr(feature = "openapi", schema(example = "atlassian-mcp-server"))]
269 pub name: String,
270 #[serde(skip_serializing_if = "Option::is_none")]
272 #[cfg_attr(
273 feature = "openapi",
274 schema(example = "Atlassian MCP Server for Jira and Confluence")
275 )]
276 pub description: Option<String>,
277 #[cfg_attr(
279 feature = "openapi",
280 schema(example = "https://mcp.atlassian.com/v1/mcp")
281 )]
282 pub url: String,
283 pub transport_type: McpServerTransportType,
285 pub status: McpServerStatus,
287 #[serde(default)]
289 pub auth_mode: McpServerAuthMode,
290 #[serde(default, skip_serializing_if = "McpProtocolMode::is_auto")]
292 pub protocol_mode: McpProtocolMode,
293 #[serde(skip_serializing_if = "Option::is_none")]
295 pub oauth_provider_id: Option<String>,
296 pub api_key_set: bool,
298 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
301 pub headers: HashMap<String, String>,
302 pub created_at: DateTime<Utc>,
304 pub updated_at: DateTime<Utc>,
306 #[serde(skip_serializing_if = "Option::is_none")]
308 pub archived_at: Option<DateTime<Utc>>,
309 #[serde(skip_serializing_if = "Option::is_none")]
311 pub deleted_at: Option<DateTime<Utc>>,
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
320#[cfg_attr(feature = "openapi", derive(ToSchema))]
321pub struct ScopedMcpServer {
322 #[serde(
324 default = "default_scoped_transport_type",
325 rename = "type",
326 alias = "transport_type"
327 )]
328 pub transport_type: McpServerTransportType,
329 #[serde(default, skip_serializing_if = "String::is_empty")]
332 pub url: String,
333 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
335 pub headers: HashMap<String, String>,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub command: Option<String>,
339 #[serde(default, skip_serializing_if = "Vec::is_empty")]
341 pub args: Vec<String>,
342 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
344 pub env: HashMap<String, String>,
345 #[serde(default, skip_serializing_if = "McpServerAuthMode::is_none")]
347 pub auth_mode: McpServerAuthMode,
348 #[serde(default, skip_serializing_if = "McpProtocolMode::is_auto")]
350 pub protocol_mode: McpProtocolMode,
351 #[serde(skip_serializing_if = "Option::is_none")]
353 pub oauth_provider_id: Option<String>,
354 #[serde(
356 default = "default_scoped_tool_discovery",
357 skip_serializing_if = "is_true"
358 )]
359 pub tool_discovery: bool,
360}
361
362impl Default for ScopedMcpServer {
363 fn default() -> Self {
364 Self {
365 transport_type: McpServerTransportType::Http,
366 url: String::new(),
367 headers: HashMap::new(),
368 auth_mode: McpServerAuthMode::None,
369 protocol_mode: McpProtocolMode::Auto,
370 oauth_provider_id: None,
371 tool_discovery: true,
372 command: None,
373 args: Vec::new(),
374 env: HashMap::new(),
375 }
376 }
377}
378
379pub type ScopedMcpServers = BTreeMap<String, ScopedMcpServer>;
380
381fn default_scoped_transport_type() -> McpServerTransportType {
382 McpServerTransportType::Http
383}
384
385fn default_scoped_tool_discovery() -> bool {
386 true
387}
388
389fn is_true(value: &bool) -> bool {
390 *value
391}
392
393pub fn scoped_mcp_servers_is_empty(servers: &ScopedMcpServers) -> bool {
394 servers.is_empty()
395}
396
397pub fn merge_scoped_mcp_servers(
399 base: &ScopedMcpServers,
400 overlay: &ScopedMcpServers,
401) -> ScopedMcpServers {
402 let mut merged = base.clone();
403 merged.extend(overlay.clone());
404 merged
405}
406
407#[derive(Debug, Clone, Serialize, Deserialize)]
414#[cfg_attr(feature = "openapi", derive(ToSchema))]
415pub struct McpToolDefinition {
416 pub name: String,
418 #[serde(skip_serializing_if = "Option::is_none")]
420 pub description: Option<String>,
421 #[serde(rename = "inputSchema")]
423 pub input_schema: Value,
424 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub annotations: Option<McpToolAnnotations>,
428}
429
430#[derive(Debug, Clone, Serialize, Deserialize, Default)]
433#[cfg_attr(feature = "openapi", derive(ToSchema))]
434pub struct McpToolAnnotations {
435 #[serde(
436 default,
437 skip_serializing_if = "Option::is_none",
438 rename = "readOnlyHint"
439 )]
440 pub read_only_hint: Option<bool>,
441 #[serde(
442 default,
443 skip_serializing_if = "Option::is_none",
444 rename = "destructiveHint"
445 )]
446 pub destructive_hint: Option<bool>,
447 #[serde(
448 default,
449 skip_serializing_if = "Option::is_none",
450 rename = "idempotentHint"
451 )]
452 pub idempotent_hint: Option<bool>,
453 #[serde(
454 default,
455 skip_serializing_if = "Option::is_none",
456 rename = "openWorldHint"
457 )]
458 pub open_world_hint: Option<bool>,
459}
460
461#[derive(Debug, Clone, Serialize, Deserialize)]
463pub struct McpToolsListRequest {
464 pub jsonrpc: String,
465 pub id: i64,
466 pub method: String,
467}
468
469impl Default for McpToolsListRequest {
470 fn default() -> Self {
471 Self {
472 jsonrpc: "2.0".to_string(),
473 id: 1,
474 method: "tools/list".to_string(),
475 }
476 }
477}
478
479#[derive(Debug, Clone, Serialize, Deserialize)]
481pub struct McpToolsListResponse {
482 pub jsonrpc: String,
483 pub id: i64,
484 #[serde(default)]
485 pub result: Option<McpToolsListResult>,
486 #[serde(default)]
487 pub error: Option<McpError>,
488}
489
490#[derive(Debug, Clone, Serialize, Deserialize)]
492pub struct McpToolsListResult {
493 pub tools: Vec<McpToolDefinition>,
494 #[serde(rename = "nextCursor", skip_serializing_if = "Option::is_none")]
495 pub next_cursor: Option<String>,
496}
497
498#[derive(Debug, Clone, Serialize, Deserialize)]
500pub struct McpError {
501 pub code: i64,
502 pub message: String,
503 #[serde(skip_serializing_if = "Option::is_none")]
504 pub data: Option<Value>,
505}
506
507#[derive(Debug, Clone, Serialize, Deserialize)]
509pub struct McpToolCallRequest {
510 pub jsonrpc: String,
511 pub id: i64,
512 pub method: String,
513 pub params: McpToolCallParams,
514}
515
516#[derive(Debug, Clone, Serialize, Deserialize)]
518pub struct McpToolCallParams {
519 pub name: String,
520 #[serde(default, skip_serializing_if = "Option::is_none")]
521 pub arguments: Option<Value>,
522}
523
524impl McpToolCallRequest {
525 pub fn new(id: i64, name: String, arguments: Option<Value>) -> Self {
526 Self {
527 jsonrpc: "2.0".to_string(),
528 id,
529 method: "tools/call".to_string(),
530 params: McpToolCallParams { name, arguments },
531 }
532 }
533}
534
535#[derive(Debug, Clone, Serialize, Deserialize)]
537pub struct McpToolCallResponse {
538 pub jsonrpc: String,
539 pub id: i64,
540 #[serde(default)]
541 pub result: Option<McpToolCallResult>,
542 #[serde(default)]
543 pub error: Option<McpError>,
544}
545
546#[derive(Debug, Clone, Serialize, Deserialize)]
548pub struct McpToolCallResult {
549 pub content: Vec<McpContent>,
550 #[serde(rename = "isError", default)]
551 pub is_error: bool,
552}
553
554#[derive(Debug, Clone, Serialize, Deserialize)]
556#[serde(tag = "type")]
557pub enum McpContent {
558 #[serde(rename = "text")]
559 Text { text: String },
560 #[serde(rename = "image")]
561 Image { data: String, mime_type: String },
562 #[serde(rename = "resource")]
563 Resource {
564 uri: String,
565 mime_type: Option<String>,
566 text: Option<String>,
567 },
568}
569
570pub fn mcp_tool_name(server_name: &str, tool_name: &str) -> String {
574 format!(
575 "mcp_{}__{}",
576 sanitize_mcp_server_name(server_name),
577 tool_name
578 )
579}
580
581pub fn sanitize_mcp_server_name(server_name: &str) -> String {
583 server_name
584 .to_lowercase()
585 .chars()
586 .map(|c| if c.is_alphanumeric() { c } else { '_' })
587 .collect::<String>()
588}
589
590pub fn is_mcp_tool(tool_name: &str) -> bool {
592 tool_name.starts_with("mcp_")
593}
594
595pub fn parse_mcp_tool_name(tool_name: &str) -> Option<(String, String)> {
599 if !tool_name.starts_with("mcp_") {
600 return None;
601 }
602 let rest = &tool_name[4..]; if let Some(pos) = rest.find("__") {
605 let server_prefix = rest[..pos].to_string();
606 let original_name = rest[pos + 2..].to_string(); if !server_prefix.is_empty() && !original_name.is_empty() {
608 return Some((server_prefix, original_name));
609 }
610 }
611 None
612}
613
614pub fn mcp_oauth_provider_id_for_uuid(server_id: uuid::Uuid) -> String {
616 format!("mcp_oauth_{}", server_id)
617}
618
619pub fn mcp_oauth_session_secret_name(server_id: uuid::Uuid, field: &str) -> String {
621 format!("mcp_oauth:{}:{}", server_id, field)
622}
623
624#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
636#[cfg_attr(feature = "openapi", derive(ToSchema))]
637#[serde(rename_all = "snake_case")]
638pub enum McpErrorCode {
639 ToolNotFound,
641 ToolTimeout,
643 ToolPanicked,
645 InvalidArguments,
647 PermissionDenied,
650 QuotaExceeded,
652 NetworkBlocked,
654 McpServerUnreachable,
657 Internal,
660 #[serde(other)]
663 Unknown,
664}
665
666impl McpErrorCode {
667 pub fn as_str(&self) -> &'static str {
670 match self {
671 McpErrorCode::ToolNotFound => "tool_not_found",
672 McpErrorCode::ToolTimeout => "tool_timeout",
673 McpErrorCode::ToolPanicked => "tool_panicked",
674 McpErrorCode::InvalidArguments => "invalid_arguments",
675 McpErrorCode::PermissionDenied => "permission_denied",
676 McpErrorCode::QuotaExceeded => "quota_exceeded",
677 McpErrorCode::NetworkBlocked => "network_blocked",
678 McpErrorCode::McpServerUnreachable => "mcp_server_unreachable",
679 McpErrorCode::Internal => "internal",
680 McpErrorCode::Unknown => "unknown",
681 }
682 }
683
684 pub fn default_category(&self) -> McpErrorCategory {
688 match self {
689 McpErrorCode::ToolTimeout
690 | McpErrorCode::McpServerUnreachable
691 | McpErrorCode::QuotaExceeded => McpErrorCategory::Transient,
692 McpErrorCode::InvalidArguments => McpErrorCategory::Validation,
693 McpErrorCode::PermissionDenied => McpErrorCategory::Auth,
694 McpErrorCode::ToolNotFound
695 | McpErrorCode::ToolPanicked
696 | McpErrorCode::NetworkBlocked => McpErrorCategory::Permanent,
697 McpErrorCode::Internal | McpErrorCode::Unknown => McpErrorCategory::Permanent,
698 }
699 }
700
701 pub fn default_retryable(&self) -> bool {
704 matches!(
705 self,
706 McpErrorCode::ToolTimeout
707 | McpErrorCode::McpServerUnreachable
708 | McpErrorCode::QuotaExceeded
709 )
710 }
711}
712
713#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
718#[cfg_attr(feature = "openapi", derive(ToSchema))]
719#[serde(rename_all = "snake_case")]
720pub enum McpErrorCategory {
721 Transient,
723 Permanent,
725 Validation,
727 Auth,
729 #[serde(other)]
731 Unknown,
732}
733
734#[derive(Debug, Clone, Serialize, Deserialize)]
741#[cfg_attr(feature = "openapi", derive(ToSchema))]
742pub struct McpExecuteError {
743 pub code: McpErrorCode,
746 pub message: String,
749 pub category: McpErrorCategory,
751 pub retryable: bool,
755 #[serde(skip_serializing_if = "Option::is_none")]
759 pub retry_after_seconds: Option<u32>,
760 #[serde(skip_serializing_if = "Option::is_none")]
762 pub hint: Option<String>,
763 #[serde(default, skip_serializing_if = "Vec::is_empty")]
766 pub cause_chain: Vec<String>,
767}
768
769impl McpExecuteError {
770 pub fn new(code: McpErrorCode, message: impl Into<String>) -> Self {
773 Self {
774 category: code.default_category(),
775 retryable: code.default_retryable(),
776 code,
777 message: message.into(),
778 retry_after_seconds: None,
779 hint: None,
780 cause_chain: Vec::new(),
781 }
782 }
783
784 pub fn with_category(mut self, category: McpErrorCategory) -> Self {
785 self.category = category;
786 self
787 }
788
789 pub fn with_retryable(mut self, retryable: bool) -> Self {
790 self.retryable = retryable;
791 self
792 }
793
794 pub fn with_retry_after_seconds(mut self, seconds: u32) -> Self {
795 self.retry_after_seconds = Some(seconds);
796 self
797 }
798
799 pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
800 self.hint = Some(hint.into());
801 self
802 }
803
804 pub fn with_cause(mut self, cause: impl Into<String>) -> Self {
805 self.cause_chain.push(cause.into());
806 self
807 }
808}
809
810pub fn classify_mcp_execute_error(message: &str) -> McpExecuteError {
823 let lower = message.to_ascii_lowercase();
824 let code = if lower.starts_with("bad_request:") || lower.starts_with("unprocessable:") {
830 McpErrorCode::InvalidArguments
831 } else if lower.starts_with("not_found:") {
832 McpErrorCode::ToolNotFound
833 } else if lower.starts_with("conflict:") {
834 McpErrorCode::InvalidArguments
838 } else if lower.starts_with("forbidden:") {
839 McpErrorCode::PermissionDenied
840 } else if lower.starts_with("internal:") {
841 McpErrorCode::Internal
842 } else if lower.contains("timed out") || lower.contains("timeout") {
844 McpErrorCode::ToolTimeout
845 } else if lower.starts_with("unknown tool") {
846 McpErrorCode::ToolNotFound
847 } else if lower.starts_with("missing required parameter") || lower.contains("invalid argument")
848 {
849 McpErrorCode::InvalidArguments
850 } else if lower.contains("permission denied")
851 || lower.contains("forbidden")
852 || lower.contains("not authorized")
853 || lower.contains("unauthorized")
854 {
855 McpErrorCode::PermissionDenied
856 } else if lower.contains("quota") || lower.contains("rate limit") {
857 McpErrorCode::QuotaExceeded
858 } else if lower.contains("network blocked") || lower.contains("egress") {
859 McpErrorCode::NetworkBlocked
860 } else if lower.contains("mcp server") && lower.contains("unreachable") {
861 McpErrorCode::McpServerUnreachable
862 } else if lower.contains("panicked") {
863 McpErrorCode::ToolPanicked
864 } else {
865 McpErrorCode::Internal
866 };
867 McpExecuteError::new(code, message)
868}
869
870#[cfg(test)]
871mod tests {
872 use super::*;
873
874 #[test]
875 fn protocol_mode_defaults_to_auto() {
876 assert_eq!(McpProtocolMode::default(), McpProtocolMode::Auto);
877 assert!(McpProtocolMode::default().is_auto());
878 }
879
880 #[test]
881 fn protocol_mode_serde_round_trips_snake_case() {
882 for (mode, json) in [
883 (McpProtocolMode::Auto, "\"auto\""),
884 (McpProtocolMode::Legacy, "\"legacy\""),
885 (McpProtocolMode::Stable, "\"stable\""),
886 (McpProtocolMode::Rc, "\"rc\""),
887 ] {
888 assert_eq!(serde_json::to_string(&mode).unwrap(), json);
889 let back: McpProtocolMode = serde_json::from_str(json).unwrap();
890 assert_eq!(back, mode);
891 }
892 }
893
894 #[test]
895 fn protocol_mode_pinned_version_and_statefulness() {
896 assert_eq!(McpProtocolMode::Auto.pinned_version(), None);
897 assert_eq!(McpProtocolMode::Auto.pinned_stateful(), None);
898 assert_eq!(
899 McpProtocolMode::Legacy.pinned_version(),
900 Some(MCP_PROTOCOL_VERSION_LEGACY)
901 );
902 assert_eq!(McpProtocolMode::Legacy.pinned_stateful(), Some(true));
903 assert_eq!(
904 McpProtocolMode::Stable.pinned_version(),
905 Some(MCP_PROTOCOL_VERSION_STABLE)
906 );
907 assert_eq!(McpProtocolMode::Stable.pinned_stateful(), Some(true));
908 assert_eq!(
909 McpProtocolMode::Rc.pinned_version(),
910 Some(MCP_PROTOCOL_VERSION_RC)
911 );
912 assert_eq!(McpProtocolMode::Rc.pinned_stateful(), Some(false));
913 }
914
915 #[test]
916 fn scoped_mcp_server_omits_auto_protocol_mode_but_keeps_pinned() {
917 let auto = ScopedMcpServer {
919 url: "https://example.com/mcp".to_string(),
920 ..Default::default()
921 };
922 let json = serde_json::to_value(&auto).unwrap();
923 assert!(
924 json.get("protocol_mode").is_none(),
925 "auto protocol_mode must not serialize: {json}"
926 );
927
928 let pinned = ScopedMcpServer {
930 url: "https://example.com/mcp".to_string(),
931 protocol_mode: McpProtocolMode::Legacy,
932 ..Default::default()
933 };
934 let json = serde_json::to_value(&pinned).unwrap();
935 assert_eq!(
936 json.get("protocol_mode").and_then(|v| v.as_str()),
937 Some("legacy")
938 );
939 }
940
941 #[test]
942 fn scoped_mcp_server_parses_protocol_mode_from_mcp_json_shape() {
943 let with_mode: ScopedMcpServer = serde_json::from_value(serde_json::json!({
945 "type": "http",
946 "url": "https://example.com/mcp",
947 "protocol_mode": "rc"
948 }))
949 .unwrap();
950 assert_eq!(with_mode.protocol_mode, McpProtocolMode::Rc);
951
952 let without_mode: ScopedMcpServer = serde_json::from_value(serde_json::json!({
953 "type": "http",
954 "url": "https://example.com/mcp"
955 }))
956 .unwrap();
957 assert_eq!(without_mode.protocol_mode, McpProtocolMode::Auto);
958 }
959
960 #[test]
961 fn merge_scoped_mcp_servers_lets_later_layer_override_protocol_mode() {
962 let mut base = ScopedMcpServers::default();
964 base.insert(
965 "docs".to_string(),
966 ScopedMcpServer {
967 url: "https://example.com/mcp".to_string(),
968 protocol_mode: McpProtocolMode::Auto,
969 ..Default::default()
970 },
971 );
972 let mut overlay = ScopedMcpServers::default();
973 overlay.insert(
974 "docs".to_string(),
975 ScopedMcpServer {
976 url: "https://example.com/mcp".to_string(),
977 protocol_mode: McpProtocolMode::Legacy,
978 ..Default::default()
979 },
980 );
981 let merged = merge_scoped_mcp_servers(&base, &overlay);
982 assert_eq!(
983 merged.get("docs").unwrap().protocol_mode,
984 McpProtocolMode::Legacy
985 );
986 }
987
988 #[test]
989 fn normalize_mcp_error_code_maps_legacy_to_rc() {
990 assert_eq!(normalize_mcp_error_code(-32002), -32602);
992 assert_eq!(normalize_mcp_error_code(-32602), -32602);
993 assert_eq!(normalize_mcp_error_code(-32601), -32601);
994 assert_eq!(normalize_mcp_error_code(0), 0);
995 }
996
997 #[test]
998 fn test_mcp_tool_name_simple() {
999 assert_eq!(mcp_tool_name("github", "search"), "mcp_github__search");
1001 }
1002
1003 #[test]
1004 fn test_mcp_tool_name_with_underscores() {
1005 assert_eq!(
1007 mcp_tool_name("microsoft_learn", "docs_search"),
1008 "mcp_microsoft_learn__docs_search"
1009 );
1010 }
1011
1012 #[test]
1013 fn test_mcp_tool_name_with_dashes() {
1014 assert_eq!(
1016 mcp_tool_name("microsoft-learn", "search"),
1017 "mcp_microsoft_learn__search"
1018 );
1019 }
1020
1021 #[test]
1022 fn test_mcp_tool_name_uppercase() {
1023 assert_eq!(mcp_tool_name("GitHub", "search"), "mcp_github__search");
1025 }
1026
1027 #[test]
1028 fn test_mcp_tool_name_special_chars() {
1029 assert_eq!(
1031 mcp_tool_name("my.server.name", "tool"),
1032 "mcp_my_server_name__tool"
1033 );
1034 }
1035
1036 #[test]
1037 fn test_is_mcp_tool() {
1038 assert!(is_mcp_tool("mcp_github__search"));
1039 assert!(is_mcp_tool("mcp_microsoft_learn__docs_search"));
1040 assert!(!is_mcp_tool("get_weather"));
1041 assert!(!is_mcp_tool("mcpsearch")); }
1043
1044 #[test]
1045 fn test_parse_mcp_tool_name_simple() {
1046 let result = parse_mcp_tool_name("mcp_github__search");
1047 assert_eq!(result, Some(("github".to_string(), "search".to_string())));
1048 }
1049
1050 #[test]
1051 fn test_parse_mcp_tool_name_with_underscores() {
1052 let result = parse_mcp_tool_name("mcp_microsoft_learn__docs_search");
1054 assert_eq!(
1055 result,
1056 Some(("microsoft_learn".to_string(), "docs_search".to_string()))
1057 );
1058 }
1059
1060 #[test]
1061 fn test_parse_mcp_tool_name_complex() {
1062 let result = parse_mcp_tool_name("mcp_my_long_server_name__my_complex_tool");
1064 assert_eq!(
1065 result,
1066 Some((
1067 "my_long_server_name".to_string(),
1068 "my_complex_tool".to_string()
1069 ))
1070 );
1071 }
1072
1073 #[test]
1074 fn test_parse_mcp_tool_name_invalid_prefix() {
1075 assert_eq!(parse_mcp_tool_name("get_weather"), None);
1077 }
1078
1079 #[test]
1080 fn test_parse_mcp_tool_name_no_separator() {
1081 assert_eq!(parse_mcp_tool_name("mcp_github_search"), None);
1083 }
1084
1085 #[test]
1086 fn test_parse_mcp_tool_name_empty_parts() {
1087 assert_eq!(parse_mcp_tool_name("mcp___search"), None);
1089 assert_eq!(parse_mcp_tool_name("mcp_github__"), None);
1090 }
1091
1092 #[test]
1093 fn test_roundtrip() {
1094 let server = "microsoft_learn";
1096 let tool = "docs_search";
1097 let full_name = mcp_tool_name(server, tool);
1098 let parsed = parse_mcp_tool_name(&full_name);
1099 assert_eq!(
1100 parsed,
1101 Some(("microsoft_learn".to_string(), "docs_search".to_string()))
1102 );
1103 }
1104
1105 #[test]
1110 fn mcp_error_code_serializes_to_snake_case_wire_string() {
1111 assert_eq!(
1112 serde_json::to_string(&McpErrorCode::ToolTimeout).unwrap(),
1113 "\"tool_timeout\""
1114 );
1115 assert_eq!(
1116 serde_json::to_string(&McpErrorCode::McpServerUnreachable).unwrap(),
1117 "\"mcp_server_unreachable\""
1118 );
1119 }
1120
1121 #[test]
1122 fn mcp_error_code_as_str_matches_serde_wire() {
1123 for code in [
1124 McpErrorCode::ToolNotFound,
1125 McpErrorCode::ToolTimeout,
1126 McpErrorCode::ToolPanicked,
1127 McpErrorCode::InvalidArguments,
1128 McpErrorCode::PermissionDenied,
1129 McpErrorCode::QuotaExceeded,
1130 McpErrorCode::NetworkBlocked,
1131 McpErrorCode::McpServerUnreachable,
1132 McpErrorCode::Internal,
1133 McpErrorCode::Unknown,
1134 ] {
1135 let wire = serde_json::to_string(&code).unwrap();
1136 assert_eq!(
1137 wire,
1138 format!("\"{}\"", code.as_str()),
1139 "as_str() must match serde wire for {code:?}"
1140 );
1141 }
1142 }
1143
1144 #[test]
1145 fn mcp_error_code_unknown_variant_is_forward_compat_sentinel() {
1146 let code: McpErrorCode = serde_json::from_str("\"future_code_we_dont_know_yet\"").unwrap();
1149 assert_eq!(code, McpErrorCode::Unknown);
1150 }
1151
1152 #[test]
1153 fn classify_recognises_timeout_substrings() {
1154 let err = classify_mcp_execute_error("Tool timed out after 30000ms");
1155 assert_eq!(err.code, McpErrorCode::ToolTimeout);
1156 assert_eq!(err.category, McpErrorCategory::Transient);
1157 assert!(err.retryable);
1158
1159 let err = classify_mcp_execute_error("Command timed out after 5000ms");
1160 assert_eq!(err.code, McpErrorCode::ToolTimeout);
1161 }
1162
1163 #[test]
1164 fn classify_recognises_tool_not_found() {
1165 let err = classify_mcp_execute_error("Unknown tool: github.foo");
1166 assert_eq!(err.code, McpErrorCode::ToolNotFound);
1167 assert_eq!(err.category, McpErrorCategory::Permanent);
1168 assert!(!err.retryable);
1169 }
1170
1171 #[test]
1172 fn classify_recognises_invalid_arguments() {
1173 let err = classify_mcp_execute_error("Missing required parameter: query");
1174 assert_eq!(err.code, McpErrorCode::InvalidArguments);
1175 assert_eq!(err.category, McpErrorCategory::Validation);
1176 assert!(!err.retryable);
1177 }
1178
1179 #[test]
1180 fn classify_recognises_permission_denied() {
1181 for msg in [
1182 "permission denied for org",
1183 "Forbidden: org scope not allowed",
1184 "not authorized to call this tool",
1185 "Unauthorized request",
1186 ] {
1187 let err = classify_mcp_execute_error(msg);
1188 assert_eq!(
1189 err.code,
1190 McpErrorCode::PermissionDenied,
1191 "expected PermissionDenied for {msg:?}"
1192 );
1193 assert_eq!(err.category, McpErrorCategory::Auth);
1194 }
1195 }
1196
1197 #[test]
1198 fn classify_recognises_quota_and_rate_limit() {
1199 let err = classify_mcp_execute_error("Quota exceeded for org");
1200 assert_eq!(err.code, McpErrorCode::QuotaExceeded);
1201 assert!(err.retryable);
1202
1203 let err = classify_mcp_execute_error("Rate limit hit");
1204 assert_eq!(err.code, McpErrorCode::QuotaExceeded);
1205 }
1206
1207 #[test]
1208 fn classify_recognises_catalog_dispatch_prefixes() {
1209 for (prefix, expected) in [
1215 (
1216 "bad_request: name must be <=200 chars",
1217 McpErrorCode::InvalidArguments,
1218 ),
1219 (
1220 "unprocessable: cycle detected in capability graph",
1221 McpErrorCode::InvalidArguments,
1222 ),
1223 (
1224 "conflict: session is already paused",
1225 McpErrorCode::InvalidArguments,
1226 ),
1227 (
1228 "not_found: agent agent_xyz not in this org",
1229 McpErrorCode::ToolNotFound,
1230 ),
1231 (
1232 "forbidden: principal lacks SESSION_WRITE",
1233 McpErrorCode::PermissionDenied,
1234 ),
1235 (
1236 "internal: storage backend returned 503",
1237 McpErrorCode::Internal,
1238 ),
1239 ] {
1240 let err = classify_mcp_execute_error(prefix);
1241 assert_eq!(err.code, expected, "expected {expected:?} for {prefix:?}");
1242 }
1243 }
1244
1245 #[test]
1246 fn classify_falls_open_to_internal() {
1247 let err = classify_mcp_execute_error("strange unanticipated message");
1251 assert_eq!(err.code, McpErrorCode::Internal);
1252 assert_eq!(err.category, McpErrorCategory::Permanent);
1253 assert!(!err.retryable);
1254 }
1255
1256 #[test]
1257 fn mcp_execute_error_skips_empty_optional_fields() {
1258 let err = McpExecuteError::new(McpErrorCode::ToolNotFound, "no such tool");
1259 let value = serde_json::to_value(&err).unwrap();
1260 assert_eq!(value["code"], "tool_not_found");
1262 assert_eq!(value["message"], "no such tool");
1263 assert_eq!(value["category"], "permanent");
1264 assert_eq!(value["retryable"], false);
1265 assert!(value.get("retry_after_seconds").is_none());
1267 assert!(value.get("hint").is_none());
1268 assert!(value.get("cause_chain").is_none());
1269 }
1270
1271 #[test]
1272 fn mcp_execute_error_builders_chain() {
1273 let err = McpExecuteError::new(McpErrorCode::ToolTimeout, "tool timed out after 30000ms")
1274 .with_retry_after_seconds(10)
1275 .with_hint("Reduce input size before retrying.")
1276 .with_cause("downstream: upstream gateway timeout");
1277 let value = serde_json::to_value(&err).unwrap();
1278 assert_eq!(value["code"], "tool_timeout");
1279 assert_eq!(value["retry_after_seconds"], 10);
1280 assert_eq!(value["hint"], "Reduce input size before retrying.");
1281 assert_eq!(
1282 value["cause_chain"][0],
1283 "downstream: upstream gateway timeout"
1284 );
1285 }
1286}