use std::collections::HashMap;
use std::time::{Duration, Instant};
use secrecy::SecretString;
use serde::Deserialize;
pub(super) const CATALOG_TTL: Duration = Duration::from_secs(5 * 60);
pub(super) const SERVER_TIMEOUT: Duration = Duration::from_secs(5);
pub const MCP_ROLE_AGENTIC_WORKER: &str = "agentic_worker";
pub const MCP_ROLE_FLOW_EDITOR: &str = "flow_editor";
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Transport {
#[default]
Http,
LocalWasm,
}
#[derive(Deserialize)]
pub(super) struct WireRow {
pub(super) id: String,
#[allow(dead_code)]
pub(super) name: String,
#[serde(default)]
pub(super) transport_url: Option<String>,
pub(super) auth_header_name: Option<String>,
pub(super) auth_token: Option<String>,
pub(super) allowed_tools: Option<Vec<String>>,
#[serde(default)]
pub(super) roles: Vec<String>,
#[serde(default)]
pub(super) transport: Transport,
pub(super) component_ref: Option<String>,
pub(super) component_version: Option<String>,
#[serde(default)]
pub(super) component_digest: Option<String>,
}
#[derive(Deserialize)]
pub(super) struct WireBody {
pub(super) servers: Vec<WireRow>,
}
pub(super) struct ParsedServer {
pub(super) id: String,
pub(super) transport_url: String,
pub(super) auth_header_name: Option<String>,
pub(super) auth_token: Option<SecretString>,
pub(super) allowed_tools: Option<Vec<String>>,
pub(super) roles: Vec<String>,
pub(super) transport: Transport,
pub(super) component_ref: Option<String>,
pub(super) component_version: Option<String>,
pub(super) component_digest: Option<String>,
}
#[derive(Clone, Debug)]
pub struct McpToolEntry {
pub description: String,
pub parameters: serde_json::Value,
}
#[derive(Clone)]
pub struct McpRoute {
pub(super) server_id: String,
pub(super) transport_url: String,
pub(super) auth_header_name: Option<String>,
pub(super) auth_token: Option<SecretString>,
pub(super) raw_tool_name: String,
pub transport: Transport,
pub component_ref: Option<String>,
pub component_version: Option<String>,
pub component_digest: Option<String>,
}
impl McpRoute {
#[allow(clippy::too_many_arguments)]
pub fn from_parts(
server_id: &str,
transport_url: &str,
auth_header_name: Option<&str>,
auth_token: Option<&str>,
transport: &str,
component_ref: Option<&str>,
component_version: Option<&str>,
component_digest: Option<&str>,
) -> Self {
Self {
server_id: server_id.to_string(),
transport_url: transport_url.to_string(),
auth_header_name: auth_header_name.map(str::to_string),
auth_token: auth_token.map(SecretString::from),
raw_tool_name: String::new(),
transport: if transport == "local-wasm" {
Transport::LocalWasm
} else {
Transport::Http
},
component_ref: component_ref.map(str::to_string),
component_version: component_version.map(str::to_string),
component_digest: component_digest.map(str::to_string),
}
}
#[must_use]
pub fn with_tool(mut self, raw_tool_name: &str) -> Self {
self.raw_tool_name = raw_tool_name.to_string();
self
}
}
impl std::fmt::Debug for McpRoute {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("McpRoute")
.field("server_id", &self.server_id)
.field("transport_url", &self.transport_url)
.field("auth_header_name", &self.auth_header_name)
.field(
"auth_token",
&self.auth_token.as_ref().map(|_| "[REDACTED]"),
)
.field("raw_tool_name", &self.raw_tool_name)
.field("transport", &self.transport)
.field("component_ref", &self.component_ref)
.field("component_version", &self.component_version)
.field("component_digest", &self.component_digest)
.finish()
}
}
pub struct McpToolCatalog {
pub(super) tools: HashMap<(String, String), McpToolEntry>,
pub(super) routes: HashMap<(String, String), McpRoute>,
pub(super) fetched_at: Instant,
}
impl McpToolCatalog {
pub(super) fn empty() -> Self {
Self {
tools: HashMap::new(),
routes: HashMap::new(),
fetched_at: Instant::now(),
}
}
pub fn tools(&self) -> impl Iterator<Item = (&(String, String), &McpToolEntry)> {
self.tools.iter()
}
pub fn len(&self) -> usize {
self.tools.len()
}
pub fn is_empty(&self) -> bool {
self.tools.is_empty()
}
pub fn tool_entry(&self, server_id: &str, tool: &str) -> Option<&McpToolEntry> {
self.tools.get(&(server_id.to_string(), tool.to_string()))
}
pub fn route(&self, server_id: &str, tool: &str) -> Option<&McpRoute> {
self.routes.get(&(server_id.to_string(), tool.to_string()))
}
#[cfg(test)]
pub(crate) fn for_tests(
tools: HashMap<(String, String), McpToolEntry>,
routes: HashMap<(String, String), McpRoute>,
) -> Self {
Self {
tools,
routes,
fetched_at: Instant::now(),
}
}
}
#[cfg(test)]
pub(crate) fn route_for_tests(server_id: &str, tool: &str, transport_url: &str) -> McpRoute {
McpRoute {
server_id: server_id.to_string(),
transport_url: transport_url.to_string(),
auth_header_name: None,
auth_token: None,
raw_tool_name: tool.to_string(),
transport: Transport::Http,
component_ref: None,
component_version: None,
component_digest: None,
}
}