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,
#[serde(rename = "oauth", alias = "o_auth")]
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)
}
}
pub const MCP_PROTOCOL_VERSION_2025_03: &str = "2025-03-26";
pub const MCP_PROTOCOL_VERSION_2025_06: &str = "2025-06-18";
pub const MCP_PROTOCOL_VERSION_2026_07: &str = "2026-07-28";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[cfg_attr(feature = "openapi", schema(example = "auto"))]
#[serde(rename_all = "snake_case")]
pub enum McpProtocolMode {
#[default]
Auto,
#[serde(rename = "2025-03-26", alias = "legacy")]
V2025March,
#[serde(rename = "2025-06-18", alias = "stable")]
V2025June,
#[serde(rename = "2026-07-28", alias = "rc")]
V2026July,
}
impl McpProtocolMode {
pub fn is_auto(&self) -> bool {
matches!(self, McpProtocolMode::Auto)
}
pub fn pinned_version(&self) -> Option<&'static str> {
match self {
McpProtocolMode::Auto => None,
McpProtocolMode::V2025March => Some(MCP_PROTOCOL_VERSION_2025_03),
McpProtocolMode::V2025June => Some(MCP_PROTOCOL_VERSION_2025_06),
McpProtocolMode::V2026July => Some(MCP_PROTOCOL_VERSION_2026_07),
}
}
pub fn pinned_stateful(&self) -> Option<bool> {
match self {
McpProtocolMode::Auto => None,
McpProtocolMode::V2025March | McpProtocolMode::V2025June => Some(true),
McpProtocolMode::V2026July => Some(false),
}
}
}
impl std::fmt::Display for McpProtocolMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
McpProtocolMode::Auto => write!(f, "auto"),
McpProtocolMode::V2025March => write!(f, "{MCP_PROTOCOL_VERSION_2025_03}"),
McpProtocolMode::V2025June => write!(f, "{MCP_PROTOCOL_VERSION_2025_06}"),
McpProtocolMode::V2026July => write!(f, "{MCP_PROTOCOL_VERSION_2026_07}"),
}
}
}
impl From<&str> for McpProtocolMode {
fn from(s: &str) -> Self {
match s {
MCP_PROTOCOL_VERSION_2025_03 | "legacy" => McpProtocolMode::V2025March,
MCP_PROTOCOL_VERSION_2025_06 | "stable" => McpProtocolMode::V2025June,
MCP_PROTOCOL_VERSION_2026_07 | "rc" => McpProtocolMode::V2026July,
_ => McpProtocolMode::Auto,
}
}
}
pub fn normalize_mcp_error_code(code: i64) -> i64 {
match code {
-32002 => -32602,
other => other,
}
}
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(default, skip_serializing_if = "McpProtocolMode::is_auto")]
pub protocol_mode: McpProtocolMode,
#[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(default, skip_serializing_if = "McpProtocolMode::is_auto")]
pub protocol_mode: McpProtocolMode,
#[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,
protocol_mode: McpProtocolMode::Auto,
oauth_provider_id: None,
tool_discovery: true,
command: None,
args: Vec::new(),
env: HashMap::new(),
}
}
}
pub type ScopedMcpServers = BTreeMap<String, ScopedMcpServer>;
#[derive(Debug, Clone)]
pub struct McpSecretBindingMetadata {
pub server_name: String,
pub tool_name: String,
pub parameter_name: String,
pub configured: bool,
pub setup_url: String,
}
pub fn apply_mcp_secret_binding_schemas(
definitions: &mut [crate::ToolDefinition],
bindings: &[McpSecretBindingMetadata],
) {
for binding in bindings {
if !is_valid_mcp_server_name(&binding.server_name) {
continue;
}
let tool_name = crate::mcp_tool_name(&binding.server_name, &binding.tool_name);
let Some(crate::ToolDefinition::Builtin(definition)) = definitions
.iter_mut()
.find(|definition| definition.name() == tool_name)
else {
continue;
};
remove_bound_parameter(&mut definition.parameters, &binding.parameter_name);
if let Some(full) = definition.full_parameters.as_mut() {
remove_bound_parameter(full, &binding.parameter_name);
}
let status = if binding.configured {
"configured"
} else {
"setup required"
};
definition.description.push_str(&format!(
"\n\nCredential '{}' is securely bound ({status}); do not request or supply it. Setup: {}",
binding.parameter_name, binding.setup_url
));
}
}
fn remove_bound_parameter(schema: &mut Value, parameter_name: &str) {
let Some(object) = schema.as_object_mut() else {
return;
};
if let Some(properties) = object.get_mut("properties").and_then(Value::as_object_mut) {
properties.remove(parameter_name);
}
if let Some(required) = object.get_mut("required").and_then(Value::as_array_mut) {
required.retain(|value| value.as_str() != Some(parameter_name));
}
}
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_valid_mcp_server_name(server_name: &str) -> bool {
let prefix = sanitize_mcp_server_name(server_name);
!prefix.is_empty() && !prefix.contains("__") && !prefix.ends_with('_')
}
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::*;
use serde_json::json;
#[test]
fn bound_parameters_are_removed_from_both_schemas_of_only_the_matching_tool() {
use crate::tool_types::{BuiltinTool, ToolDefinition};
let schema = json!({"type":"object","properties":{"message":{"type":"string"},"channel_key":{"type":"string"}},"required":["message","channel_key"],"additionalProperties":false});
let builtin = |name: &str| {
ToolDefinition::Builtin(BuiltinTool {
name: name.into(),
display_name: None,
description: "Send message".into(),
parameters: schema.clone(),
policy: Default::default(),
category: None,
deferrable: Default::default(),
hints: Default::default(),
full_parameters: Some(schema.clone()),
})
};
let mut definitions = vec![
builtin("mcp_notify__send"),
builtin("mcp_other__send"),
builtin("mcp_notify__read"),
];
let unrelated = serde_json::to_value(&definitions[1..]).unwrap();
for configured in [true, false] {
definitions[0] = builtin("mcp_notify__send");
apply_mcp_secret_binding_schemas(
&mut definitions,
&[
McpSecretBindingMetadata {
server_name: "missing".into(),
tool_name: "send".into(),
parameter_name: "message".into(),
configured: true,
setup_url: "/missing".into(),
},
McpSecretBindingMetadata {
server_name: "Notify".into(),
tool_name: "send".into(),
parameter_name: "channel_key".into(),
configured,
setup_url: "/agent/credentials".into(),
},
],
);
let ToolDefinition::Builtin(bound) = &definitions[0] else {
panic!("expected builtin")
};
let expected = json!({"type":"object","properties":{"message":{"type":"string"}},"required":["message"],"additionalProperties":false});
assert_eq!(bound.parameters, expected);
assert_eq!(bound.full_parameters.as_ref(), Some(&expected));
let status = if configured {
"configured"
} else {
"setup required"
};
assert_eq!(
bound.description,
format!(
"Send message\n\nCredential 'channel_key' is securely bound ({status}); do not request or supply it. Setup: /agent/credentials"
)
);
assert_eq!(serde_json::to_value(&definitions[1..]).unwrap(), unrelated);
}
}
#[test]
fn bound_parameter_removal_preserves_missing_or_nonobject_schema_parts() {
for mut schema in [
json!(null),
json!([]),
json!({}),
json!({"properties":[],"required":"message"}),
json!({"properties":{"message":{}},"required":["message",7]}),
] {
let original = schema.clone();
remove_bound_parameter(&mut schema, "channel_key");
assert_eq!(schema, original);
}
}
#[test]
fn protocol_modes_accept_aliases_but_emit_canonical_versions_and_policies() {
for (mode, canonical, alias, version, stateful) in [
(McpProtocolMode::Auto, "auto", "auto", None, None),
(
McpProtocolMode::V2025March,
"2025-03-26",
"legacy",
Some("2025-03-26"),
Some(true),
),
(
McpProtocolMode::V2025June,
"2025-06-18",
"stable",
Some("2025-06-18"),
Some(true),
),
(
McpProtocolMode::V2026July,
"2026-07-28",
"rc",
Some("2026-07-28"),
Some(false),
),
] {
assert_eq!(serde_json::to_value(mode).unwrap(), json!(canonical));
assert_eq!(mode.to_string(), canonical);
assert_eq!(mode.pinned_version(), version);
assert_eq!(mode.pinned_stateful(), stateful);
assert_eq!(mode.is_auto(), canonical == "auto");
for input in [canonical, alias] {
assert_eq!(McpProtocolMode::from(input), mode);
assert_eq!(
serde_json::from_value::<McpProtocolMode>(json!(input)).unwrap(),
mode
);
}
}
assert_eq!(McpProtocolMode::from("nonsense"), McpProtocolMode::Auto);
assert!(serde_json::from_value::<McpProtocolMode>(json!("nonsense")).is_err());
}
#[test]
fn scoped_config_defaults_omit_optional_values_and_canonicalize_aliases() {
for (input, expected_mode, expected_wire) in [
(
json!({"url":"https://example.com/mcp"}),
McpProtocolMode::Auto,
json!({"type":"http","url":"https://example.com/mcp"}),
),
(
json!({"type":"http","url":"https://example.com/mcp","protocol_mode":"rc"}),
McpProtocolMode::V2026July,
json!({"type":"http","url":"https://example.com/mcp","protocol_mode":"2026-07-28"}),
),
(
json!({"transport_type":"http","url":"https://example.com/mcp","protocol_mode":"legacy"}),
McpProtocolMode::V2025March,
json!({"type":"http","url":"https://example.com/mcp","protocol_mode":"2025-03-26"}),
),
] {
let config: ScopedMcpServer = serde_json::from_value(input).unwrap();
assert_eq!(config.protocol_mode, expected_mode);
assert!(config.tool_discovery);
assert_eq!(serde_json::to_value(config).unwrap(), expected_wire);
}
let defaults = ScopedMcpServer::default();
assert_eq!(defaults.protocol_mode, McpProtocolMode::Auto);
assert_eq!(
serde_json::to_value(defaults).unwrap(),
json!({"type":"http"})
);
}
#[test]
fn scoped_config_preserves_nondefault_transport_auth_and_discovery_fields() {
let wire = json!({"type":"stdio","command":"mcp-server","args":["--project","demo"],"env":{"MODE":"test"},"headers":{"X-Trace":"trace"},"auth_mode":"oauth","oauth_provider_id":"provider","protocol_mode":"2025-06-18","tool_discovery":false});
let config: ScopedMcpServer = serde_json::from_value(wire.clone()).unwrap();
assert!(config.transport_type.is_local());
assert!(!config.tool_discovery);
assert_eq!(config.auth_mode, McpServerAuthMode::OAuth);
assert_eq!(serde_json::to_value(config).unwrap(), wire);
}
#[test]
fn scoped_merge_replaces_entire_matching_connection_without_mutating_inputs() {
let base: ScopedMcpServers = serde_json::from_value(json!({
"base_only":{"url":"https://base.test/mcp"},
"shared":{"url":"https://old.test/mcp","headers":{"old":"value"},"protocol_mode":"2025-03-26","tool_discovery":false}
})).unwrap();
let overlay: ScopedMcpServers = serde_json::from_value(json!({
"overlay_only":{"url":"https://overlay.test/mcp"},
"shared":{"url":"https://new.test/mcp","headers":{"new":"value"},"protocol_mode":"2026-07-28","auth_mode":"oauth","oauth_provider_id":"provider"}
})).unwrap();
let before_base = base.clone();
let before_overlay = overlay.clone();
let merged = merge_scoped_mcp_servers(&base, &overlay);
let expected = BTreeMap::from([
("base_only".into(), base["base_only"].clone()),
("shared".into(), overlay["shared"].clone()),
("overlay_only".into(), overlay["overlay_only"].clone()),
]);
assert_eq!(merged, expected);
assert_eq!(base, before_base);
assert_eq!(overlay, before_overlay);
assert_eq!(merge_scoped_mcp_servers(&base, &BTreeMap::new()), base);
assert_eq!(
merge_scoped_mcp_servers(&BTreeMap::new(), &overlay),
overlay
);
}
#[test]
fn normalize_mcp_error_code_maps_legacy_to_rc() {
for (input, expected) in [
(-32002, -32602),
(-32602, -32602),
(-32601, -32601),
(0, 0),
(i64::MIN, i64::MIN),
(i64::MAX, i64::MAX),
] {
assert_eq!(normalize_mcp_error_code(input), expected);
}
}
#[test]
fn tool_names_sanitize_server_names_and_preserve_tool_components() {
for (server, tool, full, prefix) in [
("github", "search", "mcp_github__search", "github"),
(
"microsoft_learn",
"docs_search",
"mcp_microsoft_learn__docs_search",
"microsoft_learn",
),
(
"microsoft-learn",
"search",
"mcp_microsoft_learn__search",
"microsoft_learn",
),
("GitHub", "search", "mcp_github__search", "github"),
(
"my.server.name",
"tool",
"mcp_my_server_name__tool",
"my_server_name",
),
(
"my_long_server_name",
"my_complex_tool",
"mcp_my_long_server_name__my_complex_tool",
"my_long_server_name",
),
("github", "read__file", "mcp_github__read__file", "github"),
] {
assert_eq!(mcp_tool_name(server, tool), full);
assert_eq!(
parse_mcp_tool_name(full),
Some((prefix.into(), tool.into()))
);
assert!(is_mcp_tool(full));
}
}
#[test]
fn tool_name_parser_rejects_missing_prefix_separator_or_components() {
for name in [
"get_weather",
"mcpsearch",
"mcp_github_search",
"mcp___search",
"mcp_github__",
"mcp_",
"",
] {
assert_eq!(parse_mcp_tool_name(name), None, "{name}");
}
assert!(!is_mcp_tool("get_weather"));
assert!(!is_mcp_tool("mcpsearch"));
assert!(is_mcp_tool("mcp_"));
}
#[test]
fn error_codes_have_independent_wire_category_and_retry_contracts() {
for (code, wire, category, retryable) in [
(
McpErrorCode::ToolNotFound,
"tool_not_found",
McpErrorCategory::Permanent,
false,
),
(
McpErrorCode::ToolTimeout,
"tool_timeout",
McpErrorCategory::Transient,
true,
),
(
McpErrorCode::ToolPanicked,
"tool_panicked",
McpErrorCategory::Permanent,
false,
),
(
McpErrorCode::InvalidArguments,
"invalid_arguments",
McpErrorCategory::Validation,
false,
),
(
McpErrorCode::PermissionDenied,
"permission_denied",
McpErrorCategory::Auth,
false,
),
(
McpErrorCode::QuotaExceeded,
"quota_exceeded",
McpErrorCategory::Transient,
true,
),
(
McpErrorCode::NetworkBlocked,
"network_blocked",
McpErrorCategory::Permanent,
false,
),
(
McpErrorCode::McpServerUnreachable,
"mcp_server_unreachable",
McpErrorCategory::Transient,
true,
),
(
McpErrorCode::Internal,
"internal",
McpErrorCategory::Permanent,
false,
),
(
McpErrorCode::Unknown,
"unknown",
McpErrorCategory::Permanent,
false,
),
] {
assert_eq!(code.as_str(), wire);
assert_eq!(serde_json::to_value(code).unwrap(), json!(wire));
assert_eq!(
serde_json::from_value::<McpErrorCode>(json!(wire)).unwrap(),
code
);
let error = McpExecuteError::new(code, "original message");
assert_eq!(error.category, category);
assert_eq!(error.retryable, retryable);
assert_eq!(error.message, "original message");
}
}
#[test]
fn unknown_error_code_and_category_deserialize_to_forward_compatible_sentinels() {
assert_eq!(
serde_json::from_value::<McpErrorCode>(json!("future_code")).unwrap(),
McpErrorCode::Unknown
);
assert_eq!(
serde_json::from_value::<McpErrorCategory>(json!("future_category")).unwrap(),
McpErrorCategory::Unknown
);
}
#[test]
fn error_classifier_preserves_messages_and_routes_every_marker_and_prefix() {
for (message, code, category, retryable) in [
(
"Tool timed out after 30000ms",
McpErrorCode::ToolTimeout,
McpErrorCategory::Transient,
true,
),
(
"Command timed out after 5000ms",
McpErrorCode::ToolTimeout,
McpErrorCategory::Transient,
true,
),
(
"TIMEOUT",
McpErrorCode::ToolTimeout,
McpErrorCategory::Transient,
true,
),
(
"Unknown tool: github.foo",
McpErrorCode::ToolNotFound,
McpErrorCategory::Permanent,
false,
),
(
"Missing required parameter: query",
McpErrorCode::InvalidArguments,
McpErrorCategory::Validation,
false,
),
(
"invalid argument: query",
McpErrorCode::InvalidArguments,
McpErrorCategory::Validation,
false,
),
(
"permission denied for org",
McpErrorCode::PermissionDenied,
McpErrorCategory::Auth,
false,
),
(
"Forbidden: org scope not allowed",
McpErrorCode::PermissionDenied,
McpErrorCategory::Auth,
false,
),
(
"not authorized to call this tool",
McpErrorCode::PermissionDenied,
McpErrorCategory::Auth,
false,
),
(
"Unauthorized request",
McpErrorCode::PermissionDenied,
McpErrorCategory::Auth,
false,
),
(
"Quota exceeded for org",
McpErrorCode::QuotaExceeded,
McpErrorCategory::Transient,
true,
),
(
"Rate limit hit",
McpErrorCode::QuotaExceeded,
McpErrorCategory::Transient,
true,
),
(
"network blocked",
McpErrorCode::NetworkBlocked,
McpErrorCategory::Permanent,
false,
),
(
"EGRESS denied",
McpErrorCode::NetworkBlocked,
McpErrorCategory::Permanent,
false,
),
(
"MCP server unreachable",
McpErrorCode::McpServerUnreachable,
McpErrorCategory::Transient,
true,
),
(
"tool panicked",
McpErrorCode::ToolPanicked,
McpErrorCategory::Permanent,
false,
),
(
"bad_request: name must be <=200 chars",
McpErrorCode::InvalidArguments,
McpErrorCategory::Validation,
false,
),
(
"unprocessable: cycle detected in capability graph",
McpErrorCode::InvalidArguments,
McpErrorCategory::Validation,
false,
),
(
"conflict: session is already paused",
McpErrorCode::InvalidArguments,
McpErrorCategory::Validation,
false,
),
(
"not_found: agent agent_xyz not in this org",
McpErrorCode::ToolNotFound,
McpErrorCategory::Permanent,
false,
),
(
"forbidden: principal lacks SESSION_WRITE",
McpErrorCode::PermissionDenied,
McpErrorCategory::Auth,
false,
),
(
"internal: storage backend returned 503",
McpErrorCode::Internal,
McpErrorCategory::Permanent,
false,
),
(
"INTERNAL: upstream timed out",
McpErrorCode::Internal,
McpErrorCategory::Permanent,
false,
),
(
"bad_request: invalid timeout",
McpErrorCode::InvalidArguments,
McpErrorCategory::Validation,
false,
),
(
"strange unanticipated message",
McpErrorCode::Internal,
McpErrorCategory::Permanent,
false,
),
(
"unreachable",
McpErrorCode::Internal,
McpErrorCategory::Permanent,
false,
),
(
"mcp server available",
McpErrorCode::Internal,
McpErrorCategory::Permanent,
false,
),
(
"",
McpErrorCode::Internal,
McpErrorCategory::Permanent,
false,
),
] {
let error = classify_mcp_execute_error(message);
assert_eq!(error.code, code, "{message}");
assert_eq!(error.category, category, "{message}");
assert_eq!(error.retryable, retryable, "{message}");
assert_eq!(error.message, message);
}
}
#[test]
fn error_envelopes_omit_empty_optionals_and_preserve_explicit_overrides() {
let minimal = McpExecuteError::new(McpErrorCode::ToolNotFound, "no such tool");
assert_eq!(
serde_json::to_value(minimal).unwrap(),
json!({"code":"tool_not_found","message":"no such tool","category":"permanent","retryable":false})
);
let full = McpExecuteError::new(McpErrorCode::ToolTimeout, "tool timed out after 30000ms")
.with_category(McpErrorCategory::Auth)
.with_retryable(false)
.with_retry_after_seconds(10)
.with_hint("Reduce input size before retrying.")
.with_cause("root cause")
.with_cause("downstream: upstream gateway timeout");
assert_eq!(
serde_json::to_value(full).unwrap(),
json!({"code":"tool_timeout","message":"tool timed out after 30000ms","category":"auth","retryable":false,"retry_after_seconds":10,"hint":"Reduce input size before retrying.","cause_chain":["root cause","downstream: upstream gateway timeout"]})
);
}
#[test]
fn server_name_validation_preserves_unambiguous_generated_names() {
for name in [
"",
"_",
"docs_",
"docs-",
"docs ",
"docs__private",
"docs..private",
"--docs",
] {
assert!(!is_valid_mcp_server_name(name), "{name:?}");
}
for (name, prefix) in [
("docs", "docs"),
("docs-api", "docs_api"),
("_docs", "_docs"),
("Docs API", "docs_api"),
] {
assert!(is_valid_mcp_server_name(name), "{name:?}");
for tool in ["search", "_search", "read__file"] {
assert_eq!(
parse_mcp_tool_name(&mcp_tool_name(name, tool)),
Some((prefix.into(), tool.into()))
);
}
}
}
#[test]
fn ambiguous_bindings_do_not_rewrite_a_different_tool() {
let schema = serde_json::json!({"type":"object","properties":{"key":{"type":"string"}},"required":["key"]});
let mut definitions = vec![crate::ToolDefinition::Builtin(crate::BuiltinTool {
name: "mcp_docs___search".into(),
display_name: None,
description: "Search".into(),
parameters: schema,
policy: Default::default(),
category: None,
deferrable: Default::default(),
hints: Default::default(),
full_parameters: None,
})];
let before = serde_json::to_value(&definitions).unwrap();
let mut binding = McpSecretBindingMetadata {
server_name: "docs_".into(),
tool_name: "search".into(),
parameter_name: "key".into(),
configured: true,
setup_url: "/setup".into(),
};
apply_mcp_secret_binding_schemas(&mut definitions, &[binding.clone()]);
assert_eq!(serde_json::to_value(&definitions).unwrap(), before);
binding.server_name = "docs".into();
binding.tool_name = "_search".into();
apply_mcp_secret_binding_schemas(&mut definitions, &[binding]);
let crate::ToolDefinition::Builtin(definition) = &definitions[0] else {
panic!("expected builtin")
};
assert_eq!(
definition.parameters,
serde_json::json!({"type":"object","properties":{},"required":[]})
);
}
#[test]
fn auth_modes_use_canonical_wire_values_and_accept_legacy_oauth_spelling() {
for (mode, wire) in [
(McpServerAuthMode::None, "none"),
(McpServerAuthMode::ApiKey, "api_key"),
(McpServerAuthMode::OAuth, "oauth"),
] {
assert_eq!(serde_json::to_value(&mode).unwrap(), json!(wire));
assert_eq!(
serde_json::from_value::<McpServerAuthMode>(json!(wire)).unwrap(),
mode
);
assert_eq!(McpServerAuthMode::from(wire), mode);
assert_eq!(mode.to_string(), wire);
}
let legacy: McpServerAuthMode = serde_json::from_value(json!("o_auth")).unwrap();
assert_eq!(legacy, McpServerAuthMode::OAuth);
assert_eq!(serde_json::to_value(legacy).unwrap(), json!("oauth"));
}
}