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,
}
#[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"),
}
}
}
impl From<&str> for McpServerTransportType {
fn from(s: &str) -> Self {
match s {
"http" => McpServerTransportType::Http,
_ => 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,
pub url: String,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub headers: 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,
}
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)
}
#[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()))
);
}
}