Files
sglang/sgl-model-gateway/src/mcp/config.rs
T

529 lines
15 KiB
Rust

//! MCP configuration types and utilities.
//!
//! Defines configuration structures for MCP servers, transports, proxies, and inventory.
use std::{collections::HashMap, fmt};
pub use rmcp::model::{Prompt, RawResource, Tool};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct McpConfig {
/// Static MCP servers (loaded at startup)
pub servers: Vec<McpServerConfig>,
/// Connection pool settings
#[serde(default)]
pub pool: McpPoolConfig,
/// Global MCP proxy configuration (default for all servers)
/// Can be overridden per-server
#[serde(default)]
pub proxy: Option<McpProxyConfig>,
/// Pre-warm these connections at startup
#[serde(default)]
pub warmup: Vec<WarmupServer>,
/// Tool inventory refresh settings
#[serde(default)]
pub inventory: InventoryConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct McpServerConfig {
pub name: String,
#[serde(flatten)]
pub transport: McpTransport,
/// Per-server proxy override (overrides global proxy)
/// Set to `null` in YAML to force direct connection (no proxy)
#[serde(default)]
pub proxy: Option<McpProxyConfig>,
/// Whether this server is required for router startup
/// - true: Router startup fails if this server cannot be reached
/// - false: Log warning but continue (default)
#[serde(default)]
pub required: bool,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(tag = "protocol", rename_all = "lowercase")]
pub enum McpTransport {
Stdio {
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
envs: HashMap<String, String>,
},
Sse {
url: String,
#[serde(skip_serializing_if = "Option::is_none")]
token: Option<String>,
},
Streamable {
url: String,
#[serde(skip_serializing_if = "Option::is_none")]
token: Option<String>,
},
}
impl fmt::Debug for McpTransport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
McpTransport::Stdio {
command,
args,
envs,
} => f
.debug_struct("Stdio")
.field("command", command)
.field("args", args)
.field("envs", envs)
.finish(),
McpTransport::Sse { url, token } => f
.debug_struct("Sse")
.field("url", url)
.field("token", &token.as_ref().map(|_| "****"))
.finish(),
McpTransport::Streamable { url, token } => f
.debug_struct("Streamable")
.field("url", url)
.field("token", &token.as_ref().map(|_| "****"))
.finish(),
}
}
}
/// MCP-specific proxy configuration (does NOT affect LLM API traffic)
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct McpProxyConfig {
/// HTTP proxy URL (e.g., "http://proxy.internal:8080")
pub http: Option<String>,
/// HTTPS proxy URL
pub https: Option<String>,
/// Comma-separated hosts to exclude from proxying
/// Example: "localhost,127.0.0.1,*.internal,10.*"
pub no_proxy: Option<String>,
/// Custom proxy authentication (if needed)
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
}
/// Connection pool configuration
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct McpPoolConfig {
/// Maximum cached connections per server URL
#[serde(default = "default_max_connections")]
pub max_connections: usize,
/// Idle timeout before closing connection (seconds)
#[serde(default = "default_idle_timeout")]
pub idle_timeout: u64,
}
/// Tool inventory refresh configuration
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct InventoryConfig {
/// Enable automatic tool inventory refresh
#[serde(default = "default_true")]
pub enable_refresh: bool,
/// Tool cache TTL (seconds) - how long tools are considered fresh
#[serde(default = "default_tool_ttl")]
pub tool_ttl: u64,
/// Background refresh interval (seconds) - proactive refresh
#[serde(default = "default_refresh_interval")]
pub refresh_interval: u64,
/// Refresh on tool call failure (try refreshing if tool not found)
#[serde(default = "default_true")]
pub refresh_on_error: bool,
}
/// Pre-warm server connections at startup
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WarmupServer {
/// Server URL
pub url: String,
/// Server label/name
pub label: String,
/// Optional authentication token
#[serde(skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
}
// Default value functions
fn default_max_connections() -> usize {
100
}
fn default_idle_timeout() -> u64 {
300 // 5 minutes
}
fn default_true() -> bool {
true
}
fn default_tool_ttl() -> u64 {
300 // 5 minutes
}
fn default_refresh_interval() -> u64 {
60 // 1 minute
}
// Default implementations
impl Default for McpPoolConfig {
fn default() -> Self {
Self {
max_connections: default_max_connections(),
idle_timeout: default_idle_timeout(),
}
}
}
impl Default for InventoryConfig {
fn default() -> Self {
Self {
enable_refresh: true,
tool_ttl: default_tool_ttl(),
refresh_interval: default_refresh_interval(),
refresh_on_error: true,
}
}
}
impl McpProxyConfig {
/// Load proxy config from standard environment variables
pub fn from_env() -> Option<Self> {
let http = std::env::var("MCP_HTTP_PROXY")
.ok()
.or_else(|| std::env::var("HTTP_PROXY").ok());
let https = std::env::var("MCP_HTTPS_PROXY")
.ok()
.or_else(|| std::env::var("HTTPS_PROXY").ok());
let no_proxy = std::env::var("MCP_NO_PROXY")
.ok()
.or_else(|| std::env::var("NO_PROXY").ok());
if http.is_some() || https.is_some() {
Some(Self {
http,
https,
no_proxy,
username: None,
password: None,
})
} else {
None
}
}
}
impl McpConfig {
/// Load configuration from a YAML file
pub async fn from_file(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
let content = tokio::fs::read_to_string(path).await?;
let config: Self = serde_yaml::from_str(&content)?;
Ok(config)
}
/// Load configuration from environment variables (optional)
pub fn from_env() -> Option<Self> {
// This could be expanded to read from env vars
// For now, return None to indicate env config not implemented
None
}
/// Merge with environment-based proxy config
pub fn with_env_proxy(mut self) -> Self {
if self.proxy.is_none() {
self.proxy = McpProxyConfig::from_env();
}
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_pool_config() {
let config = McpPoolConfig::default();
assert_eq!(config.max_connections, 100);
assert_eq!(config.idle_timeout, 300);
}
#[test]
fn test_default_inventory_config() {
let config = InventoryConfig::default();
assert!(config.enable_refresh);
assert_eq!(config.tool_ttl, 300);
assert_eq!(config.refresh_interval, 60);
assert!(config.refresh_on_error);
}
#[test]
fn test_proxy_from_env_empty() {
// Ensure no proxy env vars are set for this test
std::env::remove_var("MCP_HTTP_PROXY");
std::env::remove_var("MCP_HTTPS_PROXY");
std::env::remove_var("HTTP_PROXY");
std::env::remove_var("HTTPS_PROXY");
let proxy = McpProxyConfig::from_env();
assert!(proxy.is_none(), "Should return None when no env vars set");
}
#[test]
fn test_proxy_from_env_with_vars() {
std::env::set_var("MCP_HTTP_PROXY", "http://test-proxy:8080");
std::env::set_var("MCP_NO_PROXY", "localhost,127.0.0.1");
let proxy = McpProxyConfig::from_env();
assert!(proxy.is_some(), "Should return Some when env vars set");
let proxy = proxy.unwrap();
assert_eq!(proxy.http.as_ref().unwrap(), "http://test-proxy:8080");
assert_eq!(proxy.no_proxy.as_ref().unwrap(), "localhost,127.0.0.1");
// Cleanup
std::env::remove_var("MCP_HTTP_PROXY");
std::env::remove_var("MCP_NO_PROXY");
}
#[tokio::test]
async fn test_yaml_minimal_config() {
let yaml = r#"
servers:
- name: "test-server"
protocol: sse
url: "http://localhost:3000/sse"
"#;
let config: McpConfig = serde_yaml::from_str(yaml).expect("Failed to parse YAML");
assert_eq!(config.servers.len(), 1);
assert_eq!(config.servers[0].name, "test-server");
assert!(!config.servers[0].required); // Should default to false
assert!(config.servers[0].proxy.is_none()); // Should default to None
assert_eq!(config.pool.max_connections, 100); // Should use default
assert_eq!(config.inventory.tool_ttl, 300); // Should use default
}
#[tokio::test]
async fn test_yaml_full_config() {
let yaml = r#"
# Global proxy configuration
proxy:
http: "http://global-proxy:8080"
https: "http://global-proxy:8080"
no_proxy: "localhost,127.0.0.1,*.internal"
# Connection pool settings
pool:
max_connections: 50
idle_timeout: 600
# Tool inventory settings
inventory:
enable_refresh: true
tool_ttl: 600
refresh_interval: 120
refresh_on_error: true
# Static servers
servers:
- name: "required-server"
protocol: sse
url: "https://api.example.com/sse"
token: "secret-token"
required: true
- name: "optional-server"
protocol: stdio
command: "mcp-server"
args: ["--port", "3000"]
required: false
proxy:
http: "http://server-specific-proxy:9090"
# Pre-warm connections
warmup:
- url: "http://localhost:3000/sse"
label: "local-dev"
"#;
let config: McpConfig = serde_yaml::from_str(yaml).expect("Failed to parse YAML");
// Check global proxy
assert!(config.proxy.is_some());
let global_proxy = config.proxy.as_ref().unwrap();
assert_eq!(
global_proxy.http.as_ref().unwrap(),
"http://global-proxy:8080"
);
// Check pool config
assert_eq!(config.pool.max_connections, 50);
assert_eq!(config.pool.idle_timeout, 600);
// Check inventory config
assert_eq!(config.inventory.tool_ttl, 600);
assert_eq!(config.inventory.refresh_interval, 120);
// Check servers
assert_eq!(config.servers.len(), 2);
// Required server
assert_eq!(config.servers[0].name, "required-server");
assert!(config.servers[0].required);
assert!(config.servers[0].proxy.is_none()); // Inherits global proxy
// Optional server with custom proxy
assert_eq!(config.servers[1].name, "optional-server");
assert!(!config.servers[1].required);
assert!(config.servers[1].proxy.is_some());
assert_eq!(
config.servers[1]
.proxy
.as_ref()
.unwrap()
.http
.as_ref()
.unwrap(),
"http://server-specific-proxy:9090"
);
// Check warmup
assert_eq!(config.warmup.len(), 1);
assert_eq!(config.warmup[0].label, "local-dev");
}
#[tokio::test]
async fn test_yaml_backward_compatibility() {
// Old config format should still work
let yaml = r#"
servers:
- name: "legacy-server"
protocol: sse
url: "http://localhost:3000/sse"
"#;
let config: McpConfig = serde_yaml::from_str(yaml).expect("Failed to parse old format");
assert_eq!(config.servers.len(), 1);
assert_eq!(config.servers[0].name, "legacy-server");
assert!(!config.servers[0].required); // New field defaults to false
assert!(config.servers[0].proxy.is_none()); // New field defaults to None
assert!(config.proxy.is_none()); // New field defaults to None
assert!(config.warmup.is_empty()); // New field defaults to empty
}
#[tokio::test]
async fn test_yaml_null_proxy_override() {
// Test that explicit null in YAML sets proxy to None
let yaml = r#"
proxy:
http: "http://global-proxy:8080"
servers:
- name: "direct-connection"
protocol: sse
url: "http://localhost:3000/sse"
proxy: null
"#;
let config: McpConfig = serde_yaml::from_str(yaml).expect("Failed to parse YAML");
assert!(config.proxy.is_some()); // Global proxy set
assert_eq!(config.servers.len(), 1);
assert!(config.servers[0].proxy.is_none()); // Explicitly set to null
}
#[test]
fn test_transport_stdio() {
let yaml = r#"
name: "test"
protocol: stdio
command: "mcp-server"
args: ["--port", "3000"]
envs:
VAR1: "value1"
VAR2: "value2"
"#;
let config: McpServerConfig = serde_yaml::from_str(yaml).expect("Failed to parse stdio");
assert_eq!(config.name, "test");
match config.transport {
McpTransport::Stdio {
command,
args,
envs,
} => {
assert_eq!(command, "mcp-server");
assert_eq!(args.len(), 2);
assert_eq!(args[0], "--port");
assert_eq!(envs.get("VAR1").unwrap(), "value1");
}
_ => panic!("Expected Stdio transport"),
}
}
#[test]
fn test_transport_sse() {
let yaml = r#"
name: "test"
protocol: sse
url: "http://localhost:3000/sse"
token: "secret"
"#;
let config: McpServerConfig = serde_yaml::from_str(yaml).expect("Failed to parse sse");
assert_eq!(config.name, "test");
match config.transport {
McpTransport::Sse { url, token } => {
assert_eq!(url, "http://localhost:3000/sse");
assert_eq!(token.unwrap(), "secret");
}
_ => panic!("Expected Sse transport"),
}
}
#[test]
fn test_transport_streamable() {
let yaml = r#"
name: "test"
protocol: streamable
url: "http://localhost:3000"
"#;
let config: McpServerConfig =
serde_yaml::from_str(yaml).expect("Failed to parse streamable");
assert_eq!(config.name, "test");
match config.transport {
McpTransport::Streamable { url, token } => {
assert_eq!(url, "http://localhost:3000");
assert!(token.is_none());
}
_ => panic!("Expected Streamable transport"),
}
}
}