use std::fmt;
use everruns_core::{McpServerTransportType, ScopedMcpServer};
#[derive(Clone, PartialEq, Eq)]
pub struct McpServer {
pub(crate) name: String,
pub(crate) inner: ScopedMcpServer,
}
impl McpServer {
pub fn http(name: impl Into<String>, url: impl Into<String>) -> Self {
Self {
name: name.into(),
inner: ScopedMcpServer {
transport_type: McpServerTransportType::Http,
url: url.into(),
..ScopedMcpServer::default()
},
}
}
#[cfg(feature = "mcp-stdio")]
pub fn stdio(name: impl Into<String>, command: impl Into<String>) -> Self {
Self {
name: name.into(),
inner: ScopedMcpServer {
transport_type: McpServerTransportType::Stdio,
command: Some(command.into()),
..ScopedMcpServer::default()
},
}
}
#[cfg(feature = "mcp-stdio")]
pub fn arg(mut self, arg: impl Into<String>) -> Self {
self.inner.args.push(arg.into());
self
}
#[cfg(feature = "mcp-stdio")]
pub fn args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.inner.args.extend(args.into_iter().map(Into::into));
self
}
#[cfg(feature = "mcp-stdio")]
pub fn env(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.inner.env.insert(name.into(), value.into());
self
}
pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.inner.headers.insert(name.into(), value.into());
self
}
pub fn tool_discovery(mut self, enabled: bool) -> Self {
self.inner.tool_discovery = enabled;
self
}
pub fn name(&self) -> &str {
&self.name
}
}
impl fmt::Debug for McpServer {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let header_names = self.inner.headers.keys().cloned().collect::<Vec<_>>();
let env_names = self.inner.env.keys().cloned().collect::<Vec<_>>();
f.debug_struct("McpServer")
.field("name", &self.name)
.field("transport", &self.inner.transport_type)
.field("url_configured", &!self.inner.url.is_empty())
.field("command_configured", &self.inner.command.is_some())
.field("arg_count", &self.inner.args.len())
.field("header_names", &header_names)
.field("env_names", &env_names)
.field("tool_discovery", &self.inner.tool_discovery)
.finish()
}
}
pub(crate) fn into_scoped(
servers: Vec<McpServer>,
) -> Result<everruns_core::ScopedMcpServers, String> {
let mut scoped = everruns_core::ScopedMcpServers::new();
for server in servers {
let name = server.name.trim();
if name.is_empty() {
return Err("MCP server name must not be blank".to_string());
}
match server.inner.transport_type {
McpServerTransportType::Http if server.inner.url.trim().is_empty() => {
return Err(format!("HTTP MCP server {name:?} requires a URL"));
}
McpServerTransportType::Stdio
if server
.inner
.command
.as_deref()
.is_none_or(|command| command.trim().is_empty()) =>
{
return Err(format!("stdio MCP server {name:?} requires a command"));
}
_ => {}
}
if scoped.insert(name.to_string(), server.inner).is_some() {
return Err(format!("duplicate MCP server {name:?}"));
}
}
Ok(scoped)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn debug_redacts_http_credentials_and_endpoint() {
let server = McpServer::http("remote", "https://secret.example/token")
.header("Authorization", "Bearer top-secret");
let debug = format!("{server:?}");
assert!(debug.contains("Authorization"));
assert!(!debug.contains("top-secret"));
assert!(!debug.contains("secret.example"));
}
#[cfg(feature = "mcp-stdio")]
#[test]
fn debug_redacts_stdio_command_arguments_and_environment_values() {
let server = McpServer::stdio("local", "/private/bin/server")
.arg("--api-key=top-secret")
.env("API_KEY", "top-secret");
let debug = format!("{server:?}");
assert!(debug.contains("API_KEY"));
assert!(!debug.contains("top-secret"));
assert!(!debug.contains("/private/bin/server"));
}
}