use std::{collections::BTreeMap, path::PathBuf};
use serde::{Deserialize, Deserializer, Serialize};
#[derive(Clone, Debug, Default, Serialize)]
pub(crate) struct McpConfig {
pub(crate) servers: BTreeMap<String, McpServerConfig>,
#[serde(skip)]
pub(crate) invalid_servers: Vec<InvalidMcpServer>,
}
impl McpConfig {
pub(crate) fn has_enabled_servers(&self) -> bool {
self.servers.values().any(|server| server.enabled)
}
pub(crate) fn is_empty(&self) -> bool {
self.servers.is_empty()
}
pub(crate) fn merge(&mut self, other: Self) {
for (identity, server) in other.servers {
if self.servers.insert(identity.clone(), server).is_some() {
tracing::warn!(server = %identity, "MCP server definition replaced during merge");
}
}
self.invalid_servers.extend(other.invalid_servers);
}
}
impl<'de> Deserialize<'de> for McpConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawConfig {
#[serde(default)]
servers: BTreeMap<String, toml::Value>,
}
let raw = RawConfig::deserialize(deserializer)?;
let mut config = McpConfig::default();
for (identity, value) in raw.servers {
if let Err(error) = super::validate_identity(&identity) {
config.invalid_servers.push(InvalidMcpServer {
identity,
error: error.to_string(),
});
continue;
}
match value.try_into::<McpServerConfig>() {
Ok(server) => {
config.servers.insert(identity, server);
}
Err(error) => config.invalid_servers.push(InvalidMcpServer {
identity,
error: error.to_string(),
}),
}
}
Ok(config)
}
}
#[derive(Clone, Debug)]
pub(crate) struct InvalidMcpServer {
pub(crate) identity: String,
pub(crate) error: String,
}
#[derive(Clone, Debug)]
pub(crate) struct McpFilesystemPolicy {
pub(crate) directory_root: PathBuf,
pub(crate) directory_relative_to_root: PathBuf,
pub(crate) allowed_roots: Vec<PathBuf>,
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum McpLogLevel {
Debug,
Info,
Notice,
Warning,
Error,
Critical,
Alert,
Emergency,
}
#[expect(deprecated)]
impl From<McpLogLevel> for rmcp::model::LoggingLevel {
fn from(level: McpLogLevel) -> Self {
match level {
McpLogLevel::Debug => Self::Debug,
McpLogLevel::Info => Self::Info,
McpLogLevel::Notice => Self::Notice,
McpLogLevel::Warning => Self::Warning,
McpLogLevel::Error => Self::Error,
McpLogLevel::Critical => Self::Critical,
McpLogLevel::Alert => Self::Alert,
McpLogLevel::Emergency => Self::Emergency,
}
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum McpSamplingPolicy {
#[default]
Deny,
Ask,
}
impl McpSamplingPolicy {
pub(crate) fn is_offered(self) -> bool {
match self {
Self::Deny => false,
Self::Ask => true,
}
}
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct McpOAuthConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) client_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub(crate) scopes: Vec<String>,
}
#[derive(Clone, Debug, Serialize)]
pub(crate) struct McpServerConfig {
pub(crate) enabled: bool,
pub(crate) tools: McpToolFilter,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) log_level: Option<McpLogLevel>,
pub(crate) sampling: McpSamplingPolicy,
#[serde(flatten)]
pub(crate) transport: McpTransport,
#[serde(skip)]
pub(crate) filesystem: Option<McpFilesystemPolicy>,
}
impl<'de> Deserialize<'de> for McpServerConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(tag = "transport", rename_all = "snake_case", deny_unknown_fields)]
enum RawServer {
Stdio {
#[serde(default = "enabled_by_default")]
enabled: bool,
#[serde(default)]
tools: McpToolFilter,
#[serde(default)]
log_level: Option<McpLogLevel>,
#[serde(default)]
sampling: McpSamplingPolicy,
command: String,
#[serde(default)]
args: Vec<String>,
cwd: Option<PathBuf>,
#[serde(default)]
env: BTreeMap<String, String>,
#[serde(default)]
env_from_env: BTreeMap<String, String>,
},
StreamableHttp {
#[serde(default = "enabled_by_default")]
enabled: bool,
#[serde(default)]
tools: McpToolFilter,
#[serde(default)]
log_level: Option<McpLogLevel>,
#[serde(default)]
sampling: McpSamplingPolicy,
url: String,
#[serde(default)]
headers: BTreeMap<String, String>,
#[serde(default)]
headers_from_env: BTreeMap<String, String>,
#[serde(default)]
oauth: Option<McpOAuthConfig>,
},
}
let (enabled, tools, log_level, sampling, transport) =
match RawServer::deserialize(deserializer)? {
RawServer::Stdio {
enabled,
tools,
log_level,
sampling,
command,
args,
cwd,
env,
env_from_env,
} => {
if command.trim().is_empty() {
return Err(serde::de::Error::custom("stdio command must not be empty"));
}
super::validate_stdio_environment(&env, &env_from_env)
.map_err(serde::de::Error::custom)?;
(
enabled,
tools,
log_level,
sampling,
McpTransport::Stdio {
command,
args,
cwd,
env,
env_from_env,
},
)
}
RawServer::StreamableHttp {
enabled,
tools,
log_level,
sampling,
url,
headers,
headers_from_env,
oauth,
} => {
super::parse_remote_url(&url).map_err(serde::de::Error::custom)?;
super::validate_literal_headers(&headers).map_err(serde::de::Error::custom)?;
super::validate_environment_header_names(&headers_from_env)
.map_err(serde::de::Error::custom)?;
if let Some(oauth) = &oauth {
super::validate_oauth_client(oauth.client_id.as_deref(), &oauth.scopes)
.map_err(serde::de::Error::custom)?;
}
(
enabled,
tools,
log_level,
sampling,
McpTransport::StreamableHttp {
url,
headers,
headers_from_env,
oauth,
},
)
}
};
Ok(Self {
enabled,
tools,
log_level,
sampling,
transport,
filesystem: None,
})
}
}
const fn enabled_by_default() -> bool {
true
}
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "transport", rename_all = "snake_case")]
pub(crate) enum McpTransport {
Stdio {
command: String,
args: Vec<String>,
cwd: Option<PathBuf>,
env: BTreeMap<String, String>,
env_from_env: BTreeMap<String, String>,
},
StreamableHttp {
url: String,
headers: BTreeMap<String, String>,
headers_from_env: BTreeMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
oauth: Option<McpOAuthConfig>,
},
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct McpToolFilter {
#[serde(default)]
pub(crate) allow: Vec<String>,
#[serde(default)]
pub(crate) deny: Vec<String>,
}
impl McpToolFilter {
pub(crate) fn includes(&self, name: &str) -> bool {
(self.allow.is_empty() || self.allow.iter().any(|allowed| allowed == name))
&& !self.deny.iter().any(|denied| denied == name)
}
}