use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{BTreeMap, HashMap};
use crate::typed_id::McpServerId;
#[cfg(feature = "openapi")]
use utoipa::ToSchema;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[cfg_attr(feature = "openapi", schema(example = "http"))]
#[serde(rename_all = "lowercase")]
pub enum McpServerTransportType {
Http,
Stdio,
}
impl McpServerTransportType {
pub fn is_local(&self) -> bool {
matches!(self, McpServerTransportType::Stdio)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[cfg_attr(feature = "openapi", schema(example = "api_key"))]
#[serde(rename_all = "snake_case")]
pub enum McpServerAuthMode {
#[default]
None,
ApiKey,
OAuth,
}
impl std::fmt::Display for McpServerAuthMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
McpServerAuthMode::None => write!(f, "none"),
McpServerAuthMode::ApiKey => write!(f, "api_key"),
McpServerAuthMode::OAuth => write!(f, "oauth"),
}
}
}
impl From<&str> for McpServerAuthMode {
fn from(s: &str) -> Self {
match s {
"api_key" => McpServerAuthMode::ApiKey,
"oauth" => McpServerAuthMode::OAuth,
_ => McpServerAuthMode::None,
}
}
}
impl McpServerAuthMode {
pub fn is_none(&self) -> bool {
matches!(self, McpServerAuthMode::None)
}
}
impl std::fmt::Display for McpServerTransportType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
McpServerTransportType::Http => write!(f, "http"),
McpServerTransportType::Stdio => write!(f, "stdio"),
}
}
}
impl From<&str> for McpServerTransportType {
fn from(s: &str) -> Self {
match s {
"stdio" => McpServerTransportType::Stdio,
_ => McpServerTransportType::Http,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[cfg_attr(feature = "openapi", schema(example = "active"))]
#[serde(rename_all = "lowercase")]
pub enum McpServerStatus {
Active,
Disabled,
Archived,
Deleted,
}
impl std::fmt::Display for McpServerStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
McpServerStatus::Active => write!(f, "active"),
McpServerStatus::Disabled => write!(f, "disabled"),
McpServerStatus::Archived => write!(f, "archived"),
McpServerStatus::Deleted => write!(f, "deleted"),
}
}
}
impl From<&str> for McpServerStatus {
fn from(s: &str) -> Self {
match s {
"disabled" => McpServerStatus::Disabled,
"archived" => McpServerStatus::Archived,
"deleted" => McpServerStatus::Deleted,
_ => McpServerStatus::Active,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct McpServer {
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "mcp_01933b5a00007000800000000000001"))]
pub id: McpServerId,
#[cfg_attr(feature = "openapi", schema(example = "atlassian-mcp-server"))]
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "openapi",
schema(example = "Atlassian MCP Server for Jira and Confluence")
)]
pub description: Option<String>,
#[cfg_attr(
feature = "openapi",
schema(example = "https://mcp.atlassian.com/v1/mcp")
)]
pub url: String,
pub transport_type: McpServerTransportType,
pub status: McpServerStatus,
#[serde(default)]
pub auth_mode: McpServerAuthMode,
#[serde(skip_serializing_if = "Option::is_none")]
pub oauth_provider_id: Option<String>,
pub api_key_set: bool,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub headers: HashMap<String, String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub archived_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ScopedMcpServer {
#[serde(
default = "default_scoped_transport_type",
rename = "type",
alias = "transport_type"
)]
pub transport_type: McpServerTransportType,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub url: String,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub headers: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub env: HashMap<String, String>,
#[serde(default, skip_serializing_if = "McpServerAuthMode::is_none")]
pub auth_mode: McpServerAuthMode,
#[serde(skip_serializing_if = "Option::is_none")]
pub oauth_provider_id: Option<String>,
#[serde(
default = "default_scoped_tool_discovery",
skip_serializing_if = "is_true"
)]
pub tool_discovery: bool,
}
impl Default for ScopedMcpServer {
fn default() -> Self {
Self {
transport_type: McpServerTransportType::Http,
url: String::new(),
headers: HashMap::new(),
auth_mode: McpServerAuthMode::None,
oauth_provider_id: None,
tool_discovery: true,
command: None,
args: Vec::new(),
env: HashMap::new(),
}
}
}
pub type ScopedMcpServers = BTreeMap<String, ScopedMcpServer>;
fn default_scoped_transport_type() -> McpServerTransportType {
McpServerTransportType::Http
}
fn default_scoped_tool_discovery() -> bool {
true
}
fn is_true(value: &bool) -> bool {
*value
}
pub fn scoped_mcp_servers_is_empty(servers: &ScopedMcpServers) -> bool {
servers.is_empty()
}
pub fn merge_scoped_mcp_servers(
base: &ScopedMcpServers,
overlay: &ScopedMcpServers,
) -> ScopedMcpServers {
let mut merged = base.clone();
merged.extend(overlay.clone());
merged
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct McpToolDefinition {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(rename = "inputSchema")]
pub input_schema: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub annotations: Option<McpToolAnnotations>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct McpToolAnnotations {
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "readOnlyHint"
)]
pub read_only_hint: Option<bool>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "destructiveHint"
)]
pub destructive_hint: Option<bool>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "idempotentHint"
)]
pub idempotent_hint: Option<bool>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "openWorldHint"
)]
pub open_world_hint: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolsListRequest {
pub jsonrpc: String,
pub id: i64,
pub method: String,
}
impl Default for McpToolsListRequest {
fn default() -> Self {
Self {
jsonrpc: "2.0".to_string(),
id: 1,
method: "tools/list".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolsListResponse {
pub jsonrpc: String,
pub id: i64,
#[serde(default)]
pub result: Option<McpToolsListResult>,
#[serde(default)]
pub error: Option<McpError>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolsListResult {
pub tools: Vec<McpToolDefinition>,
#[serde(rename = "nextCursor", skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpError {
pub code: i64,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolCallRequest {
pub jsonrpc: String,
pub id: i64,
pub method: String,
pub params: McpToolCallParams,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolCallParams {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub arguments: Option<Value>,
}
impl McpToolCallRequest {
pub fn new(id: i64, name: String, arguments: Option<Value>) -> Self {
Self {
jsonrpc: "2.0".to_string(),
id,
method: "tools/call".to_string(),
params: McpToolCallParams { name, arguments },
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolCallResponse {
pub jsonrpc: String,
pub id: i64,
#[serde(default)]
pub result: Option<McpToolCallResult>,
#[serde(default)]
pub error: Option<McpError>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolCallResult {
pub content: Vec<McpContent>,
#[serde(rename = "isError", default)]
pub is_error: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum McpContent {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "image")]
Image { data: String, mime_type: String },
#[serde(rename = "resource")]
Resource {
uri: String,
mime_type: Option<String>,
text: Option<String>,
},
}
pub fn mcp_tool_name(server_name: &str, tool_name: &str) -> String {
format!(
"mcp_{}__{}",
sanitize_mcp_server_name(server_name),
tool_name
)
}
pub fn sanitize_mcp_server_name(server_name: &str) -> String {
server_name
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect::<String>()
}
pub fn is_mcp_tool(tool_name: &str) -> bool {
tool_name.starts_with("mcp_")
}
pub fn parse_mcp_tool_name(tool_name: &str) -> Option<(String, String)> {
if !tool_name.starts_with("mcp_") {
return None;
}
let rest = &tool_name[4..]; if let Some(pos) = rest.find("__") {
let server_prefix = rest[..pos].to_string();
let original_name = rest[pos + 2..].to_string(); if !server_prefix.is_empty() && !original_name.is_empty() {
return Some((server_prefix, original_name));
}
}
None
}
pub fn mcp_oauth_provider_id_for_uuid(server_id: uuid::Uuid) -> String {
format!("mcp_oauth_{}", server_id)
}
pub fn mcp_oauth_session_secret_name(server_id: uuid::Uuid, field: &str) -> String {
format!("mcp_oauth:{}:{}", server_id, field)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum McpErrorCode {
ToolNotFound,
ToolTimeout,
ToolPanicked,
InvalidArguments,
PermissionDenied,
QuotaExceeded,
NetworkBlocked,
McpServerUnreachable,
Internal,
#[serde(other)]
Unknown,
}
impl McpErrorCode {
pub fn as_str(&self) -> &'static str {
match self {
McpErrorCode::ToolNotFound => "tool_not_found",
McpErrorCode::ToolTimeout => "tool_timeout",
McpErrorCode::ToolPanicked => "tool_panicked",
McpErrorCode::InvalidArguments => "invalid_arguments",
McpErrorCode::PermissionDenied => "permission_denied",
McpErrorCode::QuotaExceeded => "quota_exceeded",
McpErrorCode::NetworkBlocked => "network_blocked",
McpErrorCode::McpServerUnreachable => "mcp_server_unreachable",
McpErrorCode::Internal => "internal",
McpErrorCode::Unknown => "unknown",
}
}
pub fn default_category(&self) -> McpErrorCategory {
match self {
McpErrorCode::ToolTimeout
| McpErrorCode::McpServerUnreachable
| McpErrorCode::QuotaExceeded => McpErrorCategory::Transient,
McpErrorCode::InvalidArguments => McpErrorCategory::Validation,
McpErrorCode::PermissionDenied => McpErrorCategory::Auth,
McpErrorCode::ToolNotFound
| McpErrorCode::ToolPanicked
| McpErrorCode::NetworkBlocked => McpErrorCategory::Permanent,
McpErrorCode::Internal | McpErrorCode::Unknown => McpErrorCategory::Permanent,
}
}
pub fn default_retryable(&self) -> bool {
matches!(
self,
McpErrorCode::ToolTimeout
| McpErrorCode::McpServerUnreachable
| McpErrorCode::QuotaExceeded
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum McpErrorCategory {
Transient,
Permanent,
Validation,
Auth,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct McpExecuteError {
pub code: McpErrorCode,
pub message: String,
pub category: McpErrorCategory,
pub retryable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub retry_after_seconds: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hint: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cause_chain: Vec<String>,
}
impl McpExecuteError {
pub fn new(code: McpErrorCode, message: impl Into<String>) -> Self {
Self {
category: code.default_category(),
retryable: code.default_retryable(),
code,
message: message.into(),
retry_after_seconds: None,
hint: None,
cause_chain: Vec::new(),
}
}
pub fn with_category(mut self, category: McpErrorCategory) -> Self {
self.category = category;
self
}
pub fn with_retryable(mut self, retryable: bool) -> Self {
self.retryable = retryable;
self
}
pub fn with_retry_after_seconds(mut self, seconds: u32) -> Self {
self.retry_after_seconds = Some(seconds);
self
}
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
pub fn with_cause(mut self, cause: impl Into<String>) -> Self {
self.cause_chain.push(cause.into());
self
}
}
pub fn classify_mcp_execute_error(message: &str) -> McpExecuteError {
let lower = message.to_ascii_lowercase();
let code = if lower.starts_with("bad_request:") || lower.starts_with("unprocessable:") {
McpErrorCode::InvalidArguments
} else if lower.starts_with("not_found:") {
McpErrorCode::ToolNotFound
} else if lower.starts_with("conflict:") {
McpErrorCode::InvalidArguments
} else if lower.starts_with("forbidden:") {
McpErrorCode::PermissionDenied
} else if lower.starts_with("internal:") {
McpErrorCode::Internal
} else if lower.contains("timed out") || lower.contains("timeout") {
McpErrorCode::ToolTimeout
} else if lower.starts_with("unknown tool") {
McpErrorCode::ToolNotFound
} else if lower.starts_with("missing required parameter") || lower.contains("invalid argument")
{
McpErrorCode::InvalidArguments
} else if lower.contains("permission denied")
|| lower.contains("forbidden")
|| lower.contains("not authorized")
|| lower.contains("unauthorized")
{
McpErrorCode::PermissionDenied
} else if lower.contains("quota") || lower.contains("rate limit") {
McpErrorCode::QuotaExceeded
} else if lower.contains("network blocked") || lower.contains("egress") {
McpErrorCode::NetworkBlocked
} else if lower.contains("mcp server") && lower.contains("unreachable") {
McpErrorCode::McpServerUnreachable
} else if lower.contains("panicked") {
McpErrorCode::ToolPanicked
} else {
McpErrorCode::Internal
};
McpExecuteError::new(code, message)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mcp_tool_name_simple() {
assert_eq!(mcp_tool_name("github", "search"), "mcp_github__search");
}
#[test]
fn test_mcp_tool_name_with_underscores() {
assert_eq!(
mcp_tool_name("microsoft_learn", "docs_search"),
"mcp_microsoft_learn__docs_search"
);
}
#[test]
fn test_mcp_tool_name_with_dashes() {
assert_eq!(
mcp_tool_name("microsoft-learn", "search"),
"mcp_microsoft_learn__search"
);
}
#[test]
fn test_mcp_tool_name_uppercase() {
assert_eq!(mcp_tool_name("GitHub", "search"), "mcp_github__search");
}
#[test]
fn test_mcp_tool_name_special_chars() {
assert_eq!(
mcp_tool_name("my.server.name", "tool"),
"mcp_my_server_name__tool"
);
}
#[test]
fn test_is_mcp_tool() {
assert!(is_mcp_tool("mcp_github__search"));
assert!(is_mcp_tool("mcp_microsoft_learn__docs_search"));
assert!(!is_mcp_tool("get_weather"));
assert!(!is_mcp_tool("mcpsearch")); }
#[test]
fn test_parse_mcp_tool_name_simple() {
let result = parse_mcp_tool_name("mcp_github__search");
assert_eq!(result, Some(("github".to_string(), "search".to_string())));
}
#[test]
fn test_parse_mcp_tool_name_with_underscores() {
let result = parse_mcp_tool_name("mcp_microsoft_learn__docs_search");
assert_eq!(
result,
Some(("microsoft_learn".to_string(), "docs_search".to_string()))
);
}
#[test]
fn test_parse_mcp_tool_name_complex() {
let result = parse_mcp_tool_name("mcp_my_long_server_name__my_complex_tool");
assert_eq!(
result,
Some((
"my_long_server_name".to_string(),
"my_complex_tool".to_string()
))
);
}
#[test]
fn test_parse_mcp_tool_name_invalid_prefix() {
assert_eq!(parse_mcp_tool_name("get_weather"), None);
}
#[test]
fn test_parse_mcp_tool_name_no_separator() {
assert_eq!(parse_mcp_tool_name("mcp_github_search"), None);
}
#[test]
fn test_parse_mcp_tool_name_empty_parts() {
assert_eq!(parse_mcp_tool_name("mcp___search"), None);
assert_eq!(parse_mcp_tool_name("mcp_github__"), None);
}
#[test]
fn test_roundtrip() {
let server = "microsoft_learn";
let tool = "docs_search";
let full_name = mcp_tool_name(server, tool);
let parsed = parse_mcp_tool_name(&full_name);
assert_eq!(
parsed,
Some(("microsoft_learn".to_string(), "docs_search".to_string()))
);
}
#[test]
fn mcp_error_code_serializes_to_snake_case_wire_string() {
assert_eq!(
serde_json::to_string(&McpErrorCode::ToolTimeout).unwrap(),
"\"tool_timeout\""
);
assert_eq!(
serde_json::to_string(&McpErrorCode::McpServerUnreachable).unwrap(),
"\"mcp_server_unreachable\""
);
}
#[test]
fn mcp_error_code_as_str_matches_serde_wire() {
for code in [
McpErrorCode::ToolNotFound,
McpErrorCode::ToolTimeout,
McpErrorCode::ToolPanicked,
McpErrorCode::InvalidArguments,
McpErrorCode::PermissionDenied,
McpErrorCode::QuotaExceeded,
McpErrorCode::NetworkBlocked,
McpErrorCode::McpServerUnreachable,
McpErrorCode::Internal,
McpErrorCode::Unknown,
] {
let wire = serde_json::to_string(&code).unwrap();
assert_eq!(
wire,
format!("\"{}\"", code.as_str()),
"as_str() must match serde wire for {code:?}"
);
}
}
#[test]
fn mcp_error_code_unknown_variant_is_forward_compat_sentinel() {
let code: McpErrorCode = serde_json::from_str("\"future_code_we_dont_know_yet\"").unwrap();
assert_eq!(code, McpErrorCode::Unknown);
}
#[test]
fn classify_recognises_timeout_substrings() {
let err = classify_mcp_execute_error("Tool timed out after 30000ms");
assert_eq!(err.code, McpErrorCode::ToolTimeout);
assert_eq!(err.category, McpErrorCategory::Transient);
assert!(err.retryable);
let err = classify_mcp_execute_error("Command timed out after 5000ms");
assert_eq!(err.code, McpErrorCode::ToolTimeout);
}
#[test]
fn classify_recognises_tool_not_found() {
let err = classify_mcp_execute_error("Unknown tool: github.foo");
assert_eq!(err.code, McpErrorCode::ToolNotFound);
assert_eq!(err.category, McpErrorCategory::Permanent);
assert!(!err.retryable);
}
#[test]
fn classify_recognises_invalid_arguments() {
let err = classify_mcp_execute_error("Missing required parameter: query");
assert_eq!(err.code, McpErrorCode::InvalidArguments);
assert_eq!(err.category, McpErrorCategory::Validation);
assert!(!err.retryable);
}
#[test]
fn classify_recognises_permission_denied() {
for msg in [
"permission denied for org",
"Forbidden: org scope not allowed",
"not authorized to call this tool",
"Unauthorized request",
] {
let err = classify_mcp_execute_error(msg);
assert_eq!(
err.code,
McpErrorCode::PermissionDenied,
"expected PermissionDenied for {msg:?}"
);
assert_eq!(err.category, McpErrorCategory::Auth);
}
}
#[test]
fn classify_recognises_quota_and_rate_limit() {
let err = classify_mcp_execute_error("Quota exceeded for org");
assert_eq!(err.code, McpErrorCode::QuotaExceeded);
assert!(err.retryable);
let err = classify_mcp_execute_error("Rate limit hit");
assert_eq!(err.code, McpErrorCode::QuotaExceeded);
}
#[test]
fn classify_recognises_catalog_dispatch_prefixes() {
for (prefix, expected) in [
(
"bad_request: name must be <=200 chars",
McpErrorCode::InvalidArguments,
),
(
"unprocessable: cycle detected in capability graph",
McpErrorCode::InvalidArguments,
),
(
"conflict: session is already paused",
McpErrorCode::InvalidArguments,
),
(
"not_found: agent agent_xyz not in this org",
McpErrorCode::ToolNotFound,
),
(
"forbidden: principal lacks SESSION_WRITE",
McpErrorCode::PermissionDenied,
),
(
"internal: storage backend returned 503",
McpErrorCode::Internal,
),
] {
let err = classify_mcp_execute_error(prefix);
assert_eq!(err.code, expected, "expected {expected:?} for {prefix:?}");
}
}
#[test]
fn classify_falls_open_to_internal() {
let err = classify_mcp_execute_error("strange unanticipated message");
assert_eq!(err.code, McpErrorCode::Internal);
assert_eq!(err.category, McpErrorCategory::Permanent);
assert!(!err.retryable);
}
#[test]
fn mcp_execute_error_skips_empty_optional_fields() {
let err = McpExecuteError::new(McpErrorCode::ToolNotFound, "no such tool");
let value = serde_json::to_value(&err).unwrap();
assert_eq!(value["code"], "tool_not_found");
assert_eq!(value["message"], "no such tool");
assert_eq!(value["category"], "permanent");
assert_eq!(value["retryable"], false);
assert!(value.get("retry_after_seconds").is_none());
assert!(value.get("hint").is_none());
assert!(value.get("cause_chain").is_none());
}
#[test]
fn mcp_execute_error_builders_chain() {
let err = McpExecuteError::new(McpErrorCode::ToolTimeout, "tool timed out after 30000ms")
.with_retry_after_seconds(10)
.with_hint("Reduce input size before retrying.")
.with_cause("downstream: upstream gateway timeout");
let value = serde_json::to_value(&err).unwrap();
assert_eq!(value["code"], "tool_timeout");
assert_eq!(value["retry_after_seconds"], 10);
assert_eq!(value["hint"], "Reduce input size before retrying.");
assert_eq!(
value["cause_chain"][0],
"downstream: upstream gateway timeout"
);
}
}