#[cfg(test)]
mod tests;
use std::collections::BTreeMap;
use std::time::Duration;
use serde::Deserialize;
use url::Url;
use crate::mcp::secret::SecretString;
use crate::mcp::sse::endpoint::{EndpointError, is_loopback, validate_configured_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(60);
pub const DEFAULT_MAX_EVENT_BYTES: usize = 4 * 1024 * 1024;
pub const DEFAULT_MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
pub const DEFAULT_MAX_TOOL_PAGES: usize = 1_000;
pub const DEFAULT_MAX_TOOLS: usize = 4_096;
const RESERVED_HEADERS: [&str; 2] = ["mcp-session-id", "mcp-protocol-version"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpStreamableHttpLimits {
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_response_bytes: usize,
pub max_tool_pages: usize,
pub max_tools: usize,
}
impl Default for McpStreamableHttpLimits {
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_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
max_tool_pages: DEFAULT_MAX_TOOL_PAGES,
max_tools: DEFAULT_MAX_TOOLS,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct McpStreamableHttpServerConfig {
pub name: String,
pub url: String,
#[serde(default)]
pub headers: BTreeMap<String, SecretString>,
#[serde(default)]
pub allow_plaintext_credentials: bool,
#[serde(skip)]
pub limits: McpStreamableHttpLimits,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum McpStreamableHttpConfigError {
#[error("invalid MCP endpoint URL: {0}")]
Url(#[from] EndpointError),
#[error("MCP server name must not be empty")]
EmptyName,
#[error("invalid MCP header name '{name}'")]
InvalidHeaderName { name: String },
#[error("MCP header '{name}' has a value that is not valid for HTTP")]
InvalidHeaderValue { name: String },
#[error("MCP header '{name}' is set by the transport and must not be configured")]
ReservedHeader { 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 McpStreamableHttpServerConfig {
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: McpStreamableHttpLimits::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: McpStreamableHttpLimits) -> 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, McpStreamableHttpConfigError> {
if self.name.trim().is_empty() {
return Err(McpStreamableHttpConfigError::EmptyName);
}
let url = validate_configured_url(&self.url)?;
for (name, value) in &self.headers {
if reqwest::header::HeaderName::try_from(name.as_str()).is_err() {
return Err(McpStreamableHttpConfigError::InvalidHeaderName {
name: name.to_string(),
});
}
if RESERVED_HEADERS
.iter()
.any(|reserved| name.eq_ignore_ascii_case(reserved))
{
return Err(McpStreamableHttpConfigError::ReservedHeader {
name: name.to_string(),
});
}
if reqwest::header::HeaderValue::try_from(value.expose_secret()).is_err() {
return Err(McpStreamableHttpConfigError::InvalidHeaderValue {
name: name.to_string(),
});
}
}
if !self.headers.is_empty()
&& url.scheme() == "http"
&& !self.allow_plaintext_credentials
&& !is_loopback(&url)
{
return Err(McpStreamableHttpConfigError::PlaintextCredentials {
url: self.url.clone(),
});
}
Ok(url)
}
}