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_2025_03: &str = "2025-03-26";
108pub const MCP_PROTOCOL_VERSION_2025_06: &str = "2025-06-18";
110pub const MCP_PROTOCOL_VERSION_2026_07: &str = "2026-07-28";
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
126#[cfg_attr(feature = "openapi", derive(ToSchema))]
127#[cfg_attr(feature = "openapi", schema(example = "auto"))]
128#[serde(rename_all = "snake_case")]
129pub enum McpProtocolMode {
130 #[default]
132 Auto,
133 #[serde(rename = "2025-03-26", alias = "legacy")]
135 V2025March,
136 #[serde(rename = "2025-06-18", alias = "stable")]
138 V2025June,
139 #[serde(rename = "2026-07-28", alias = "rc")]
142 V2026July,
143}
144
145impl McpProtocolMode {
146 pub fn is_auto(&self) -> bool {
149 matches!(self, McpProtocolMode::Auto)
150 }
151
152 pub fn pinned_version(&self) -> Option<&'static str> {
155 match self {
156 McpProtocolMode::Auto => None,
157 McpProtocolMode::V2025March => Some(MCP_PROTOCOL_VERSION_2025_03),
158 McpProtocolMode::V2025June => Some(MCP_PROTOCOL_VERSION_2025_06),
159 McpProtocolMode::V2026July => Some(MCP_PROTOCOL_VERSION_2026_07),
160 }
161 }
162
163 pub fn pinned_stateful(&self) -> Option<bool> {
166 match self {
167 McpProtocolMode::Auto => None,
168 McpProtocolMode::V2025March | McpProtocolMode::V2025June => Some(true),
169 McpProtocolMode::V2026July => Some(false),
170 }
171 }
172}
173
174impl std::fmt::Display for McpProtocolMode {
175 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176 match self {
177 McpProtocolMode::Auto => write!(f, "auto"),
178 McpProtocolMode::V2025March => write!(f, "{MCP_PROTOCOL_VERSION_2025_03}"),
179 McpProtocolMode::V2025June => write!(f, "{MCP_PROTOCOL_VERSION_2025_06}"),
180 McpProtocolMode::V2026July => write!(f, "{MCP_PROTOCOL_VERSION_2026_07}"),
181 }
182 }
183}
184
185impl From<&str> for McpProtocolMode {
186 fn from(s: &str) -> Self {
190 match s {
191 MCP_PROTOCOL_VERSION_2025_03 | "legacy" => McpProtocolMode::V2025March,
192 MCP_PROTOCOL_VERSION_2025_06 | "stable" => McpProtocolMode::V2025June,
193 MCP_PROTOCOL_VERSION_2026_07 | "rc" => McpProtocolMode::V2026July,
194 _ => McpProtocolMode::Auto,
195 }
196 }
197}
198
199pub fn normalize_mcp_error_code(code: i64) -> i64 {
206 match code {
207 -32002 => -32602,
208 other => other,
209 }
210}
211
212impl std::fmt::Display for McpServerTransportType {
213 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214 match self {
215 McpServerTransportType::Http => write!(f, "http"),
216 McpServerTransportType::Stdio => write!(f, "stdio"),
217 }
218 }
219}
220
221impl From<&str> for McpServerTransportType {
222 fn from(s: &str) -> Self {
223 match s {
224 "stdio" => McpServerTransportType::Stdio,
225 _ => McpServerTransportType::Http,
227 }
228 }
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
237#[cfg_attr(feature = "openapi", derive(ToSchema))]
238#[cfg_attr(feature = "openapi", schema(example = "active"))]
239#[serde(rename_all = "lowercase")]
240pub enum McpServerStatus {
241 Active,
243 Disabled,
245 Archived,
247 Deleted,
249}
250
251impl std::fmt::Display for McpServerStatus {
252 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253 match self {
254 McpServerStatus::Active => write!(f, "active"),
255 McpServerStatus::Disabled => write!(f, "disabled"),
256 McpServerStatus::Archived => write!(f, "archived"),
257 McpServerStatus::Deleted => write!(f, "deleted"),
258 }
259 }
260}
261
262impl From<&str> for McpServerStatus {
263 fn from(s: &str) -> Self {
264 match s {
265 "disabled" => McpServerStatus::Disabled,
266 "archived" => McpServerStatus::Archived,
267 "deleted" => McpServerStatus::Deleted,
268 _ => McpServerStatus::Active,
269 }
270 }
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
276#[cfg_attr(feature = "openapi", derive(ToSchema))]
277pub struct McpServer {
278 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "mcp_01933b5a00007000800000000000001"))]
280 pub id: McpServerId,
281 #[cfg_attr(feature = "openapi", schema(example = "atlassian-mcp-server"))]
283 pub name: String,
284 #[serde(skip_serializing_if = "Option::is_none")]
286 #[cfg_attr(
287 feature = "openapi",
288 schema(example = "Atlassian MCP Server for Jira and Confluence")
289 )]
290 pub description: Option<String>,
291 #[cfg_attr(
293 feature = "openapi",
294 schema(example = "https://mcp.atlassian.com/v1/mcp")
295 )]
296 pub url: String,
297 pub transport_type: McpServerTransportType,
299 pub status: McpServerStatus,
301 #[serde(default)]
303 pub auth_mode: McpServerAuthMode,
304 #[serde(default, skip_serializing_if = "McpProtocolMode::is_auto")]
306 pub protocol_mode: McpProtocolMode,
307 #[serde(skip_serializing_if = "Option::is_none")]
309 pub oauth_provider_id: Option<String>,
310 pub api_key_set: bool,
312 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
315 pub headers: HashMap<String, String>,
316 pub created_at: DateTime<Utc>,
318 pub updated_at: DateTime<Utc>,
320 #[serde(skip_serializing_if = "Option::is_none")]
322 pub archived_at: Option<DateTime<Utc>>,
323 #[serde(skip_serializing_if = "Option::is_none")]
325 pub deleted_at: Option<DateTime<Utc>>,
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
334#[cfg_attr(feature = "openapi", derive(ToSchema))]
335pub struct ScopedMcpServer {
336 #[serde(
338 default = "default_scoped_transport_type",
339 rename = "type",
340 alias = "transport_type"
341 )]
342 pub transport_type: McpServerTransportType,
343 #[serde(default, skip_serializing_if = "String::is_empty")]
346 pub url: String,
347 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
349 pub headers: HashMap<String, String>,
350 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub command: Option<String>,
353 #[serde(default, skip_serializing_if = "Vec::is_empty")]
355 pub args: Vec<String>,
356 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
358 pub env: HashMap<String, String>,
359 #[serde(default, skip_serializing_if = "McpServerAuthMode::is_none")]
361 pub auth_mode: McpServerAuthMode,
362 #[serde(default, skip_serializing_if = "McpProtocolMode::is_auto")]
364 pub protocol_mode: McpProtocolMode,
365 #[serde(skip_serializing_if = "Option::is_none")]
367 pub oauth_provider_id: Option<String>,
368 #[serde(
370 default = "default_scoped_tool_discovery",
371 skip_serializing_if = "is_true"
372 )]
373 pub tool_discovery: bool,
374}
375
376impl Default for ScopedMcpServer {
377 fn default() -> Self {
378 Self {
379 transport_type: McpServerTransportType::Http,
380 url: String::new(),
381 headers: HashMap::new(),
382 auth_mode: McpServerAuthMode::None,
383 protocol_mode: McpProtocolMode::Auto,
384 oauth_provider_id: None,
385 tool_discovery: true,
386 command: None,
387 args: Vec::new(),
388 env: HashMap::new(),
389 }
390 }
391}
392
393pub type ScopedMcpServers = BTreeMap<String, ScopedMcpServer>;
394
395fn default_scoped_transport_type() -> McpServerTransportType {
396 McpServerTransportType::Http
397}
398
399fn default_scoped_tool_discovery() -> bool {
400 true
401}
402
403fn is_true(value: &bool) -> bool {
404 *value
405}
406
407pub fn scoped_mcp_servers_is_empty(servers: &ScopedMcpServers) -> bool {
408 servers.is_empty()
409}
410
411pub fn merge_scoped_mcp_servers(
413 base: &ScopedMcpServers,
414 overlay: &ScopedMcpServers,
415) -> ScopedMcpServers {
416 let mut merged = base.clone();
417 merged.extend(overlay.clone());
418 merged
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize)]
428#[cfg_attr(feature = "openapi", derive(ToSchema))]
429pub struct McpToolDefinition {
430 pub name: String,
432 #[serde(skip_serializing_if = "Option::is_none")]
434 pub description: Option<String>,
435 #[serde(rename = "inputSchema")]
437 pub input_schema: Value,
438 #[serde(default, skip_serializing_if = "Option::is_none")]
441 pub annotations: Option<McpToolAnnotations>,
442}
443
444#[derive(Debug, Clone, Serialize, Deserialize, Default)]
447#[cfg_attr(feature = "openapi", derive(ToSchema))]
448pub struct McpToolAnnotations {
449 #[serde(
450 default,
451 skip_serializing_if = "Option::is_none",
452 rename = "readOnlyHint"
453 )]
454 pub read_only_hint: Option<bool>,
455 #[serde(
456 default,
457 skip_serializing_if = "Option::is_none",
458 rename = "destructiveHint"
459 )]
460 pub destructive_hint: Option<bool>,
461 #[serde(
462 default,
463 skip_serializing_if = "Option::is_none",
464 rename = "idempotentHint"
465 )]
466 pub idempotent_hint: Option<bool>,
467 #[serde(
468 default,
469 skip_serializing_if = "Option::is_none",
470 rename = "openWorldHint"
471 )]
472 pub open_world_hint: Option<bool>,
473}
474
475#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct McpToolsListRequest {
478 pub jsonrpc: String,
479 pub id: i64,
480 pub method: String,
481}
482
483impl Default for McpToolsListRequest {
484 fn default() -> Self {
485 Self {
486 jsonrpc: "2.0".to_string(),
487 id: 1,
488 method: "tools/list".to_string(),
489 }
490 }
491}
492
493#[derive(Debug, Clone, Serialize, Deserialize)]
495pub struct McpToolsListResponse {
496 pub jsonrpc: String,
497 pub id: i64,
498 #[serde(default)]
499 pub result: Option<McpToolsListResult>,
500 #[serde(default)]
501 pub error: Option<McpError>,
502}
503
504#[derive(Debug, Clone, Serialize, Deserialize)]
506pub struct McpToolsListResult {
507 pub tools: Vec<McpToolDefinition>,
508 #[serde(rename = "nextCursor", skip_serializing_if = "Option::is_none")]
509 pub next_cursor: Option<String>,
510}
511
512#[derive(Debug, Clone, Serialize, Deserialize)]
514pub struct McpError {
515 pub code: i64,
516 pub message: String,
517 #[serde(skip_serializing_if = "Option::is_none")]
518 pub data: Option<Value>,
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize)]
523pub struct McpToolCallRequest {
524 pub jsonrpc: String,
525 pub id: i64,
526 pub method: String,
527 pub params: McpToolCallParams,
528}
529
530#[derive(Debug, Clone, Serialize, Deserialize)]
532pub struct McpToolCallParams {
533 pub name: String,
534 #[serde(default, skip_serializing_if = "Option::is_none")]
535 pub arguments: Option<Value>,
536}
537
538impl McpToolCallRequest {
539 pub fn new(id: i64, name: String, arguments: Option<Value>) -> Self {
540 Self {
541 jsonrpc: "2.0".to_string(),
542 id,
543 method: "tools/call".to_string(),
544 params: McpToolCallParams { name, arguments },
545 }
546 }
547}
548
549#[derive(Debug, Clone, Serialize, Deserialize)]
551pub struct McpToolCallResponse {
552 pub jsonrpc: String,
553 pub id: i64,
554 #[serde(default)]
555 pub result: Option<McpToolCallResult>,
556 #[serde(default)]
557 pub error: Option<McpError>,
558}
559
560#[derive(Debug, Clone, Serialize, Deserialize)]
562pub struct McpToolCallResult {
563 pub content: Vec<McpContent>,
564 #[serde(rename = "isError", default)]
565 pub is_error: bool,
566}
567
568#[derive(Debug, Clone, Serialize, Deserialize)]
570#[serde(tag = "type")]
571pub enum McpContent {
572 #[serde(rename = "text")]
573 Text { text: String },
574 #[serde(rename = "image")]
575 Image { data: String, mime_type: String },
576 #[serde(rename = "resource")]
577 Resource {
578 uri: String,
579 mime_type: Option<String>,
580 text: Option<String>,
581 },
582}
583
584pub fn mcp_tool_name(server_name: &str, tool_name: &str) -> String {
588 format!(
589 "mcp_{}__{}",
590 sanitize_mcp_server_name(server_name),
591 tool_name
592 )
593}
594
595pub fn sanitize_mcp_server_name(server_name: &str) -> String {
597 server_name
598 .to_lowercase()
599 .chars()
600 .map(|c| if c.is_alphanumeric() { c } else { '_' })
601 .collect::<String>()
602}
603
604pub fn is_mcp_tool(tool_name: &str) -> bool {
606 tool_name.starts_with("mcp_")
607}
608
609pub fn parse_mcp_tool_name(tool_name: &str) -> Option<(String, String)> {
613 if !tool_name.starts_with("mcp_") {
614 return None;
615 }
616 let rest = &tool_name[4..]; if let Some(pos) = rest.find("__") {
619 let server_prefix = rest[..pos].to_string();
620 let original_name = rest[pos + 2..].to_string(); if !server_prefix.is_empty() && !original_name.is_empty() {
622 return Some((server_prefix, original_name));
623 }
624 }
625 None
626}
627
628pub fn mcp_oauth_provider_id_for_uuid(server_id: uuid::Uuid) -> String {
630 format!("mcp_oauth_{}", server_id)
631}
632
633pub fn mcp_oauth_session_secret_name(server_id: uuid::Uuid, field: &str) -> String {
635 format!("mcp_oauth:{}:{}", server_id, field)
636}
637
638#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
650#[cfg_attr(feature = "openapi", derive(ToSchema))]
651#[serde(rename_all = "snake_case")]
652pub enum McpErrorCode {
653 ToolNotFound,
655 ToolTimeout,
657 ToolPanicked,
659 InvalidArguments,
661 PermissionDenied,
664 QuotaExceeded,
666 NetworkBlocked,
668 McpServerUnreachable,
671 Internal,
674 #[serde(other)]
677 Unknown,
678}
679
680impl McpErrorCode {
681 pub fn as_str(&self) -> &'static str {
684 match self {
685 McpErrorCode::ToolNotFound => "tool_not_found",
686 McpErrorCode::ToolTimeout => "tool_timeout",
687 McpErrorCode::ToolPanicked => "tool_panicked",
688 McpErrorCode::InvalidArguments => "invalid_arguments",
689 McpErrorCode::PermissionDenied => "permission_denied",
690 McpErrorCode::QuotaExceeded => "quota_exceeded",
691 McpErrorCode::NetworkBlocked => "network_blocked",
692 McpErrorCode::McpServerUnreachable => "mcp_server_unreachable",
693 McpErrorCode::Internal => "internal",
694 McpErrorCode::Unknown => "unknown",
695 }
696 }
697
698 pub fn default_category(&self) -> McpErrorCategory {
702 match self {
703 McpErrorCode::ToolTimeout
704 | McpErrorCode::McpServerUnreachable
705 | McpErrorCode::QuotaExceeded => McpErrorCategory::Transient,
706 McpErrorCode::InvalidArguments => McpErrorCategory::Validation,
707 McpErrorCode::PermissionDenied => McpErrorCategory::Auth,
708 McpErrorCode::ToolNotFound
709 | McpErrorCode::ToolPanicked
710 | McpErrorCode::NetworkBlocked => McpErrorCategory::Permanent,
711 McpErrorCode::Internal | McpErrorCode::Unknown => McpErrorCategory::Permanent,
712 }
713 }
714
715 pub fn default_retryable(&self) -> bool {
718 matches!(
719 self,
720 McpErrorCode::ToolTimeout
721 | McpErrorCode::McpServerUnreachable
722 | McpErrorCode::QuotaExceeded
723 )
724 }
725}
726
727#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
732#[cfg_attr(feature = "openapi", derive(ToSchema))]
733#[serde(rename_all = "snake_case")]
734pub enum McpErrorCategory {
735 Transient,
737 Permanent,
739 Validation,
741 Auth,
743 #[serde(other)]
745 Unknown,
746}
747
748#[derive(Debug, Clone, Serialize, Deserialize)]
755#[cfg_attr(feature = "openapi", derive(ToSchema))]
756pub struct McpExecuteError {
757 pub code: McpErrorCode,
760 pub message: String,
763 pub category: McpErrorCategory,
765 pub retryable: bool,
769 #[serde(skip_serializing_if = "Option::is_none")]
773 pub retry_after_seconds: Option<u32>,
774 #[serde(skip_serializing_if = "Option::is_none")]
776 pub hint: Option<String>,
777 #[serde(default, skip_serializing_if = "Vec::is_empty")]
780 pub cause_chain: Vec<String>,
781}
782
783impl McpExecuteError {
784 pub fn new(code: McpErrorCode, message: impl Into<String>) -> Self {
787 Self {
788 category: code.default_category(),
789 retryable: code.default_retryable(),
790 code,
791 message: message.into(),
792 retry_after_seconds: None,
793 hint: None,
794 cause_chain: Vec::new(),
795 }
796 }
797
798 pub fn with_category(mut self, category: McpErrorCategory) -> Self {
799 self.category = category;
800 self
801 }
802
803 pub fn with_retryable(mut self, retryable: bool) -> Self {
804 self.retryable = retryable;
805 self
806 }
807
808 pub fn with_retry_after_seconds(mut self, seconds: u32) -> Self {
809 self.retry_after_seconds = Some(seconds);
810 self
811 }
812
813 pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
814 self.hint = Some(hint.into());
815 self
816 }
817
818 pub fn with_cause(mut self, cause: impl Into<String>) -> Self {
819 self.cause_chain.push(cause.into());
820 self
821 }
822}
823
824pub fn classify_mcp_execute_error(message: &str) -> McpExecuteError {
837 let lower = message.to_ascii_lowercase();
838 let code = if lower.starts_with("bad_request:") || lower.starts_with("unprocessable:") {
844 McpErrorCode::InvalidArguments
845 } else if lower.starts_with("not_found:") {
846 McpErrorCode::ToolNotFound
847 } else if lower.starts_with("conflict:") {
848 McpErrorCode::InvalidArguments
852 } else if lower.starts_with("forbidden:") {
853 McpErrorCode::PermissionDenied
854 } else if lower.starts_with("internal:") {
855 McpErrorCode::Internal
856 } else if lower.contains("timed out") || lower.contains("timeout") {
858 McpErrorCode::ToolTimeout
859 } else if lower.starts_with("unknown tool") {
860 McpErrorCode::ToolNotFound
861 } else if lower.starts_with("missing required parameter") || lower.contains("invalid argument")
862 {
863 McpErrorCode::InvalidArguments
864 } else if lower.contains("permission denied")
865 || lower.contains("forbidden")
866 || lower.contains("not authorized")
867 || lower.contains("unauthorized")
868 {
869 McpErrorCode::PermissionDenied
870 } else if lower.contains("quota") || lower.contains("rate limit") {
871 McpErrorCode::QuotaExceeded
872 } else if lower.contains("network blocked") || lower.contains("egress") {
873 McpErrorCode::NetworkBlocked
874 } else if lower.contains("mcp server") && lower.contains("unreachable") {
875 McpErrorCode::McpServerUnreachable
876 } else if lower.contains("panicked") {
877 McpErrorCode::ToolPanicked
878 } else {
879 McpErrorCode::Internal
880 };
881 McpExecuteError::new(code, message)
882}
883
884#[cfg(test)]
885mod tests {
886 use super::*;
887
888 #[test]
889 fn protocol_mode_defaults_to_auto() {
890 assert_eq!(McpProtocolMode::default(), McpProtocolMode::Auto);
891 assert!(McpProtocolMode::default().is_auto());
892 }
893
894 #[test]
895 fn protocol_mode_serde_round_trips_version_dates() {
896 for (mode, json) in [
897 (McpProtocolMode::Auto, "\"auto\""),
898 (McpProtocolMode::V2025March, "\"2025-03-26\""),
899 (McpProtocolMode::V2025June, "\"2025-06-18\""),
900 (McpProtocolMode::V2026July, "\"2026-07-28\""),
901 ] {
902 assert_eq!(serde_json::to_string(&mode).unwrap(), json);
903 let back: McpProtocolMode = serde_json::from_str(json).unwrap();
904 assert_eq!(back, mode);
905 }
906 }
907
908 #[test]
909 fn protocol_mode_accepts_pre_release_aliases() {
910 for (json, expected) in [
913 ("\"legacy\"", McpProtocolMode::V2025March),
914 ("\"stable\"", McpProtocolMode::V2025June),
915 ("\"rc\"", McpProtocolMode::V2026July),
916 ] {
917 let parsed: McpProtocolMode = serde_json::from_str(json).unwrap();
918 assert_eq!(parsed, expected);
919 }
920 assert_eq!(McpProtocolMode::from("rc"), McpProtocolMode::V2026July);
921 assert_eq!(
922 McpProtocolMode::from("2026-07-28"),
923 McpProtocolMode::V2026July
924 );
925 assert_eq!(McpProtocolMode::from("nonsense"), McpProtocolMode::Auto);
926 }
927
928 #[test]
929 fn protocol_mode_pinned_version_and_statefulness() {
930 assert_eq!(McpProtocolMode::Auto.pinned_version(), None);
931 assert_eq!(McpProtocolMode::Auto.pinned_stateful(), None);
932 assert_eq!(
933 McpProtocolMode::V2025March.pinned_version(),
934 Some(MCP_PROTOCOL_VERSION_2025_03)
935 );
936 assert_eq!(McpProtocolMode::V2025March.pinned_stateful(), Some(true));
937 assert_eq!(
938 McpProtocolMode::V2025June.pinned_version(),
939 Some(MCP_PROTOCOL_VERSION_2025_06)
940 );
941 assert_eq!(McpProtocolMode::V2025June.pinned_stateful(), Some(true));
942 assert_eq!(
943 McpProtocolMode::V2026July.pinned_version(),
944 Some(MCP_PROTOCOL_VERSION_2026_07)
945 );
946 assert_eq!(McpProtocolMode::V2026July.pinned_stateful(), Some(false));
947 }
948
949 #[test]
950 fn scoped_mcp_server_omits_auto_protocol_mode_but_keeps_pinned() {
951 let auto = ScopedMcpServer {
953 url: "https://example.com/mcp".to_string(),
954 ..Default::default()
955 };
956 let json = serde_json::to_value(&auto).unwrap();
957 assert!(
958 json.get("protocol_mode").is_none(),
959 "auto protocol_mode must not serialize: {json}"
960 );
961
962 let pinned = ScopedMcpServer {
964 url: "https://example.com/mcp".to_string(),
965 protocol_mode: McpProtocolMode::V2025March,
966 ..Default::default()
967 };
968 let json = serde_json::to_value(&pinned).unwrap();
969 assert_eq!(
970 json.get("protocol_mode").and_then(|v| v.as_str()),
971 Some(MCP_PROTOCOL_VERSION_2025_03),
972 "pinned modes serialize as the version date, not the retired `legacy` alias"
973 );
974 }
975
976 #[test]
977 fn scoped_mcp_server_parses_protocol_mode_from_mcp_json_shape() {
978 let with_mode: ScopedMcpServer = serde_json::from_value(serde_json::json!({
980 "type": "http",
981 "url": "https://example.com/mcp",
982 "protocol_mode": "rc"
983 }))
984 .unwrap();
985 assert_eq!(with_mode.protocol_mode, McpProtocolMode::V2026July);
986
987 let without_mode: ScopedMcpServer = serde_json::from_value(serde_json::json!({
988 "type": "http",
989 "url": "https://example.com/mcp"
990 }))
991 .unwrap();
992 assert_eq!(without_mode.protocol_mode, McpProtocolMode::Auto);
993 }
994
995 #[test]
996 fn merge_scoped_mcp_servers_lets_later_layer_override_protocol_mode() {
997 let mut base = ScopedMcpServers::default();
999 base.insert(
1000 "docs".to_string(),
1001 ScopedMcpServer {
1002 url: "https://example.com/mcp".to_string(),
1003 protocol_mode: McpProtocolMode::Auto,
1004 ..Default::default()
1005 },
1006 );
1007 let mut overlay = ScopedMcpServers::default();
1008 overlay.insert(
1009 "docs".to_string(),
1010 ScopedMcpServer {
1011 url: "https://example.com/mcp".to_string(),
1012 protocol_mode: McpProtocolMode::V2025March,
1013 ..Default::default()
1014 },
1015 );
1016 let merged = merge_scoped_mcp_servers(&base, &overlay);
1017 assert_eq!(
1018 merged.get("docs").unwrap().protocol_mode,
1019 McpProtocolMode::V2025March
1020 );
1021 }
1022
1023 #[test]
1024 fn normalize_mcp_error_code_maps_legacy_to_rc() {
1025 assert_eq!(normalize_mcp_error_code(-32002), -32602);
1027 assert_eq!(normalize_mcp_error_code(-32602), -32602);
1028 assert_eq!(normalize_mcp_error_code(-32601), -32601);
1029 assert_eq!(normalize_mcp_error_code(0), 0);
1030 }
1031
1032 #[test]
1033 fn test_mcp_tool_name_simple() {
1034 assert_eq!(mcp_tool_name("github", "search"), "mcp_github__search");
1036 }
1037
1038 #[test]
1039 fn test_mcp_tool_name_with_underscores() {
1040 assert_eq!(
1042 mcp_tool_name("microsoft_learn", "docs_search"),
1043 "mcp_microsoft_learn__docs_search"
1044 );
1045 }
1046
1047 #[test]
1048 fn test_mcp_tool_name_with_dashes() {
1049 assert_eq!(
1051 mcp_tool_name("microsoft-learn", "search"),
1052 "mcp_microsoft_learn__search"
1053 );
1054 }
1055
1056 #[test]
1057 fn test_mcp_tool_name_uppercase() {
1058 assert_eq!(mcp_tool_name("GitHub", "search"), "mcp_github__search");
1060 }
1061
1062 #[test]
1063 fn test_mcp_tool_name_special_chars() {
1064 assert_eq!(
1066 mcp_tool_name("my.server.name", "tool"),
1067 "mcp_my_server_name__tool"
1068 );
1069 }
1070
1071 #[test]
1072 fn test_is_mcp_tool() {
1073 assert!(is_mcp_tool("mcp_github__search"));
1074 assert!(is_mcp_tool("mcp_microsoft_learn__docs_search"));
1075 assert!(!is_mcp_tool("get_weather"));
1076 assert!(!is_mcp_tool("mcpsearch")); }
1078
1079 #[test]
1080 fn test_parse_mcp_tool_name_simple() {
1081 let result = parse_mcp_tool_name("mcp_github__search");
1082 assert_eq!(result, Some(("github".to_string(), "search".to_string())));
1083 }
1084
1085 #[test]
1086 fn test_parse_mcp_tool_name_with_underscores() {
1087 let result = parse_mcp_tool_name("mcp_microsoft_learn__docs_search");
1089 assert_eq!(
1090 result,
1091 Some(("microsoft_learn".to_string(), "docs_search".to_string()))
1092 );
1093 }
1094
1095 #[test]
1096 fn test_parse_mcp_tool_name_complex() {
1097 let result = parse_mcp_tool_name("mcp_my_long_server_name__my_complex_tool");
1099 assert_eq!(
1100 result,
1101 Some((
1102 "my_long_server_name".to_string(),
1103 "my_complex_tool".to_string()
1104 ))
1105 );
1106 }
1107
1108 #[test]
1109 fn test_parse_mcp_tool_name_invalid_prefix() {
1110 assert_eq!(parse_mcp_tool_name("get_weather"), None);
1112 }
1113
1114 #[test]
1115 fn test_parse_mcp_tool_name_no_separator() {
1116 assert_eq!(parse_mcp_tool_name("mcp_github_search"), None);
1118 }
1119
1120 #[test]
1121 fn test_parse_mcp_tool_name_empty_parts() {
1122 assert_eq!(parse_mcp_tool_name("mcp___search"), None);
1124 assert_eq!(parse_mcp_tool_name("mcp_github__"), None);
1125 }
1126
1127 #[test]
1128 fn test_roundtrip() {
1129 let server = "microsoft_learn";
1131 let tool = "docs_search";
1132 let full_name = mcp_tool_name(server, tool);
1133 let parsed = parse_mcp_tool_name(&full_name);
1134 assert_eq!(
1135 parsed,
1136 Some(("microsoft_learn".to_string(), "docs_search".to_string()))
1137 );
1138 }
1139
1140 #[test]
1145 fn mcp_error_code_serializes_to_snake_case_wire_string() {
1146 assert_eq!(
1147 serde_json::to_string(&McpErrorCode::ToolTimeout).unwrap(),
1148 "\"tool_timeout\""
1149 );
1150 assert_eq!(
1151 serde_json::to_string(&McpErrorCode::McpServerUnreachable).unwrap(),
1152 "\"mcp_server_unreachable\""
1153 );
1154 }
1155
1156 #[test]
1157 fn mcp_error_code_as_str_matches_serde_wire() {
1158 for code in [
1159 McpErrorCode::ToolNotFound,
1160 McpErrorCode::ToolTimeout,
1161 McpErrorCode::ToolPanicked,
1162 McpErrorCode::InvalidArguments,
1163 McpErrorCode::PermissionDenied,
1164 McpErrorCode::QuotaExceeded,
1165 McpErrorCode::NetworkBlocked,
1166 McpErrorCode::McpServerUnreachable,
1167 McpErrorCode::Internal,
1168 McpErrorCode::Unknown,
1169 ] {
1170 let wire = serde_json::to_string(&code).unwrap();
1171 assert_eq!(
1172 wire,
1173 format!("\"{}\"", code.as_str()),
1174 "as_str() must match serde wire for {code:?}"
1175 );
1176 }
1177 }
1178
1179 #[test]
1180 fn mcp_error_code_unknown_variant_is_forward_compat_sentinel() {
1181 let code: McpErrorCode = serde_json::from_str("\"future_code_we_dont_know_yet\"").unwrap();
1184 assert_eq!(code, McpErrorCode::Unknown);
1185 }
1186
1187 #[test]
1188 fn classify_recognises_timeout_substrings() {
1189 let err = classify_mcp_execute_error("Tool timed out after 30000ms");
1190 assert_eq!(err.code, McpErrorCode::ToolTimeout);
1191 assert_eq!(err.category, McpErrorCategory::Transient);
1192 assert!(err.retryable);
1193
1194 let err = classify_mcp_execute_error("Command timed out after 5000ms");
1195 assert_eq!(err.code, McpErrorCode::ToolTimeout);
1196 }
1197
1198 #[test]
1199 fn classify_recognises_tool_not_found() {
1200 let err = classify_mcp_execute_error("Unknown tool: github.foo");
1201 assert_eq!(err.code, McpErrorCode::ToolNotFound);
1202 assert_eq!(err.category, McpErrorCategory::Permanent);
1203 assert!(!err.retryable);
1204 }
1205
1206 #[test]
1207 fn classify_recognises_invalid_arguments() {
1208 let err = classify_mcp_execute_error("Missing required parameter: query");
1209 assert_eq!(err.code, McpErrorCode::InvalidArguments);
1210 assert_eq!(err.category, McpErrorCategory::Validation);
1211 assert!(!err.retryable);
1212 }
1213
1214 #[test]
1215 fn classify_recognises_permission_denied() {
1216 for msg in [
1217 "permission denied for org",
1218 "Forbidden: org scope not allowed",
1219 "not authorized to call this tool",
1220 "Unauthorized request",
1221 ] {
1222 let err = classify_mcp_execute_error(msg);
1223 assert_eq!(
1224 err.code,
1225 McpErrorCode::PermissionDenied,
1226 "expected PermissionDenied for {msg:?}"
1227 );
1228 assert_eq!(err.category, McpErrorCategory::Auth);
1229 }
1230 }
1231
1232 #[test]
1233 fn classify_recognises_quota_and_rate_limit() {
1234 let err = classify_mcp_execute_error("Quota exceeded for org");
1235 assert_eq!(err.code, McpErrorCode::QuotaExceeded);
1236 assert!(err.retryable);
1237
1238 let err = classify_mcp_execute_error("Rate limit hit");
1239 assert_eq!(err.code, McpErrorCode::QuotaExceeded);
1240 }
1241
1242 #[test]
1243 fn classify_recognises_catalog_dispatch_prefixes() {
1244 for (prefix, expected) in [
1250 (
1251 "bad_request: name must be <=200 chars",
1252 McpErrorCode::InvalidArguments,
1253 ),
1254 (
1255 "unprocessable: cycle detected in capability graph",
1256 McpErrorCode::InvalidArguments,
1257 ),
1258 (
1259 "conflict: session is already paused",
1260 McpErrorCode::InvalidArguments,
1261 ),
1262 (
1263 "not_found: agent agent_xyz not in this org",
1264 McpErrorCode::ToolNotFound,
1265 ),
1266 (
1267 "forbidden: principal lacks SESSION_WRITE",
1268 McpErrorCode::PermissionDenied,
1269 ),
1270 (
1271 "internal: storage backend returned 503",
1272 McpErrorCode::Internal,
1273 ),
1274 ] {
1275 let err = classify_mcp_execute_error(prefix);
1276 assert_eq!(err.code, expected, "expected {expected:?} for {prefix:?}");
1277 }
1278 }
1279
1280 #[test]
1281 fn classify_falls_open_to_internal() {
1282 let err = classify_mcp_execute_error("strange unanticipated message");
1286 assert_eq!(err.code, McpErrorCode::Internal);
1287 assert_eq!(err.category, McpErrorCategory::Permanent);
1288 assert!(!err.retryable);
1289 }
1290
1291 #[test]
1292 fn mcp_execute_error_skips_empty_optional_fields() {
1293 let err = McpExecuteError::new(McpErrorCode::ToolNotFound, "no such tool");
1294 let value = serde_json::to_value(&err).unwrap();
1295 assert_eq!(value["code"], "tool_not_found");
1297 assert_eq!(value["message"], "no such tool");
1298 assert_eq!(value["category"], "permanent");
1299 assert_eq!(value["retryable"], false);
1300 assert!(value.get("retry_after_seconds").is_none());
1302 assert!(value.get("hint").is_none());
1303 assert!(value.get("cause_chain").is_none());
1304 }
1305
1306 #[test]
1307 fn mcp_execute_error_builders_chain() {
1308 let err = McpExecuteError::new(McpErrorCode::ToolTimeout, "tool timed out after 30000ms")
1309 .with_retry_after_seconds(10)
1310 .with_hint("Reduce input size before retrying.")
1311 .with_cause("downstream: upstream gateway timeout");
1312 let value = serde_json::to_value(&err).unwrap();
1313 assert_eq!(value["code"], "tool_timeout");
1314 assert_eq!(value["retry_after_seconds"], 10);
1315 assert_eq!(value["hint"], "Reduce input size before retrying.");
1316 assert_eq!(
1317 value["cause_chain"][0],
1318 "downstream: upstream gateway timeout"
1319 );
1320 }
1321}