#[cfg(test)]
mod tests;
use std::collections::BTreeMap;
use std::time::Duration;
use serde::Deserialize;
use url::Url;
use super::endpoint::{EndpointError, validate_stream_url};
pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
pub const DEFAULT_INITIALIZE_TIMEOUT: Duration = Duration::from_secs(10);
pub const DEFAULT_LIST_TOOLS_TIMEOUT: Duration = Duration::from_secs(30);
pub const DEFAULT_CALL_TOOL_TIMEOUT: Duration = Duration::from_secs(120);
pub const DEFAULT_STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(300);
pub const DEFAULT_MAX_EVENT_BYTES: usize = 4 * 1024 * 1024;
pub const DEFAULT_MAX_ENDPOINT_BYTES: usize = 8 * 1024;
pub const DEFAULT_MAX_TOOL_PAGES: usize = 1_000;
#[derive(Clone, PartialEq, Eq, Deserialize)]
#[serde(transparent)]
pub struct SecretString(String);
impl SecretString {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn expose_secret(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for SecretString {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("SecretString([redacted])")
}
}
impl<T: Into<String>> From<T> for SecretString {
fn from(value: T) -> Self {
Self::new(value)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpSseLimits {
pub connect_timeout: Duration,
pub initialize_timeout: Duration,
pub list_tools_timeout: Duration,
pub call_tool_timeout: Duration,
pub stream_idle_timeout: Duration,
pub max_event_bytes: usize,
pub max_endpoint_bytes: usize,
pub max_tool_pages: usize,
}
impl Default for McpSseLimits {
fn default() -> Self {
Self {
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
initialize_timeout: DEFAULT_INITIALIZE_TIMEOUT,
list_tools_timeout: DEFAULT_LIST_TOOLS_TIMEOUT,
call_tool_timeout: DEFAULT_CALL_TOOL_TIMEOUT,
stream_idle_timeout: DEFAULT_STREAM_IDLE_TIMEOUT,
max_event_bytes: DEFAULT_MAX_EVENT_BYTES,
max_endpoint_bytes: DEFAULT_MAX_ENDPOINT_BYTES,
max_tool_pages: DEFAULT_MAX_TOOL_PAGES,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct McpSseServerConfig {
pub name: String,
pub url: String,
#[serde(default)]
pub headers: BTreeMap<String, SecretString>,
#[serde(default)]
pub allow_plaintext_credentials: bool,
#[serde(skip)]
pub limits: McpSseLimits,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum McpSseConfigError {
#[error("invalid MCP SSE stream URL: {0}")]
Url(#[from] EndpointError),
#[error("MCP SSE server name must not be empty")]
EmptyName,
#[error("invalid MCP SSE header name '{name}'")]
InvalidHeaderName { name: String },
#[error("MCP SSE header '{name}' has a value that is not valid for HTTP")]
InvalidHeaderValue { name: String },
#[error(
"refusing to send configured headers to '{url}' over plaintext http; \
use https, a loopback host, or set allow_plaintext_credentials"
)]
PlaintextCredentials { url: String },
}
impl McpSseServerConfig {
pub fn new(name: impl Into<String>, url: impl Into<String>) -> Self {
Self {
name: name.into(),
url: url.into(),
headers: BTreeMap::new(),
allow_plaintext_credentials: false,
limits: McpSseLimits::default(),
}
}
pub fn with_header(mut self, name: impl Into<String>, value: impl Into<SecretString>) -> Self {
self.headers.insert(name.into(), value.into());
self
}
pub fn with_bearer_token(self, token: impl Into<String>) -> Self {
self.with_header(
"authorization",
SecretString::new(format!("Bearer {}", token.into())),
)
}
pub fn with_limits(mut self, limits: McpSseLimits) -> Self {
self.limits = limits;
self
}
pub fn allowing_plaintext_credentials(mut self) -> Self {
self.allow_plaintext_credentials = true;
self
}
pub fn validate(&self) -> Result<Url, McpSseConfigError> {
if self.name.trim().is_empty() {
return Err(McpSseConfigError::EmptyName);
}
let url = validate_stream_url(&self.url)?;
for (name, value) in &self.headers {
if reqwest::header::HeaderName::try_from(name.as_str()).is_err() {
return Err(McpSseConfigError::InvalidHeaderName {
name: name.to_string(),
});
}
if reqwest::header::HeaderValue::try_from(value.expose_secret()).is_err() {
return Err(McpSseConfigError::InvalidHeaderValue {
name: name.to_string(),
});
}
}
if !self.headers.is_empty()
&& url.scheme() == "http"
&& !self.allow_plaintext_credentials
&& !is_loopback(&url)
{
return Err(McpSseConfigError::PlaintextCredentials {
url: self.url.clone(),
});
}
Ok(url)
}
}
fn is_loopback(url: &Url) -> bool {
match url.host() {
Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
Some(url::Host::Ipv4(address)) => address.is_loopback(),
Some(url::Host::Ipv6(address)) => address.is_loopback(),
None => false,
}
}