pub use super::connector::{ConnectorConfig, ConnectorIdentity};
use crate::ai::ToolModelConfig;
use crate::auth::{JwtAudience, Permission};
use crate::errors::ConfigValidationError;
use crate::mcp::capabilities::ToolVisibility;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use systemprompt_identifiers::ClientId;
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
pub enum McpServerType {
#[default]
#[serde(rename = "internal")]
Internal,
#[serde(rename = "external")]
External,
}
impl McpServerType {
pub const fn as_str(&self) -> &'static str {
match self {
Self::Internal => "internal",
Self::External => "external",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ToolUiConfig {
#[serde(default = "default_resource_uri_template")]
pub resource_uri_template: String,
#[serde(default = "default_visibility_enum")]
pub visibility: Vec<ToolVisibility>,
}
fn default_resource_uri_template() -> String {
"ui://systemprompt/{artifact_id}".to_owned()
}
fn default_visibility_enum() -> Vec<ToolVisibility> {
vec![ToolVisibility::Model, ToolVisibility::App]
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ToolMetadata {
#[serde(default)]
pub terminal_on_success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_config: Option<ToolModelConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ui: Option<ToolUiConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeploymentConfig {
pub deployments: HashMap<String, Deployment>,
pub settings: Settings,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Deployment {
#[serde(default, alias = "type")]
pub server_type: McpServerType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub binary: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub package: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>,
pub enabled: bool,
pub display_in_web: bool,
#[serde(default)]
pub dev_only: bool,
#[serde(default)]
pub schemas: Vec<SchemaDefinition>,
pub oauth: OAuthRequirement,
#[serde(default)]
pub tools: HashMap<String, ToolMetadata>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_config: Option<ToolModelConfig>,
#[serde(default)]
pub env_vars: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub external_auth: Option<ExternalAuth>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connector: Option<ConnectorConfig>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub headers: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_policy: Option<crate::bridge::ids::ToolPolicy>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExternalAuth {
pub token_endpoint: String,
#[serde(default = "default_auth_header")]
pub header: String,
#[serde(default = "default_auth_scheme")]
pub scheme: String,
}
fn default_auth_header() -> String {
"Authorization".to_owned()
}
fn default_auth_scheme() -> String {
"Bearer".to_owned()
}
impl ExternalAuth {
pub fn header_value(&self, bearer: &str) -> String {
if self.scheme.trim().is_empty() {
bearer.to_owned()
} else {
format!("{} {bearer}", self.scheme)
}
}
}
impl Deployment {
pub fn validate(&self, name: &str) -> Result<(), ConfigValidationError> {
match self.server_type {
McpServerType::Internal => self.validate_internal(name)?,
McpServerType::External => self.validate_external(name)?,
}
if let Some(connector) = self.connector.as_ref() {
self.validate_connector(name, connector)?;
}
if let Some(ext) = self.external_auth.as_ref() {
validate_external_auth(name, ext)?;
}
Ok(())
}
fn validate_internal(&self, name: &str) -> Result<(), ConfigValidationError> {
if self.binary.as_deref().is_none_or(|b| b.trim().is_empty()) || self.port.is_none() {
return Err(ConfigValidationError::invalid_field(format!(
"MCP server '{name}': internal servers require a binary and a port."
)));
}
if let Some(ep) = self.endpoint.as_deref()
&& (ep.starts_with("http://") || ep.starts_with("https://"))
{
return Err(ConfigValidationError::invalid_field(format!(
"MCP server '{name}': endpoint must be a relative path (e.g. \
/api/v1/mcp/{name}/mcp) or omitted; the host is derived from \
server.api_external_url. Remove the scheme+host prefix."
)));
}
if self.external_auth.is_some() || self.connector.is_some() || !self.headers.is_empty() {
return Err(ConfigValidationError::invalid_field(format!(
"MCP server '{name}': external_auth and headers are only valid on \
external servers; internal servers are reached through the gateway \
with the systemprompt credential."
)));
}
Ok(())
}
fn validate_external(&self, name: &str) -> Result<(), ConfigValidationError> {
if self
.endpoint
.as_deref()
.is_none_or(|ep| ep.trim().is_empty())
{
return Err(ConfigValidationError::invalid_field(format!(
"MCP server '{name}': external servers require an endpoint."
)));
}
if self.binary.is_some() || self.package.is_some() || self.port.is_some() {
return Err(ConfigValidationError::invalid_field(format!(
"MCP server '{name}': binary, package and port are only valid on internal \
servers; an external server is reached at its endpoint and is never \
bound locally. Remove them."
)));
}
Ok(())
}
fn validate_connector(
&self,
name: &str,
connector: &ConnectorConfig,
) -> Result<(), ConfigValidationError> {
if connector.adapter != "generic"
|| !self
.endpoint
.as_deref()
.is_some_and(|endpoint| endpoint.starts_with("https://"))
{
return Err(ConfigValidationError::invalid_field(format!(
"MCP server '{name}': generic connector requires an HTTPS resource"
)));
}
if connector.client_secret.is_some() && connector.client_id_secret.is_none() {
return Err(ConfigValidationError::invalid_field(format!(
"MCP server '{name}': connector client secret requires a client ID"
)));
}
connector.validate(name)
}
}
fn validate_external_auth(name: &str, ext: &ExternalAuth) -> Result<(), ConfigValidationError> {
if ext.token_endpoint.starts_with("http://") || ext.token_endpoint.starts_with("https://") {
return Err(ConfigValidationError::invalid_field(format!(
"MCP server '{name}': external_auth.token_endpoint must be a relative \
path (e.g. /api/public/<provider>/token); the host is derived from \
server.api_external_url. Remove the scheme+host prefix."
)));
}
if !ext.token_endpoint.starts_with('/') {
return Err(ConfigValidationError::invalid_field(format!(
"MCP server '{name}': external_auth.token_endpoint must be an absolute \
path beginning with '/'."
)));
}
if ext.header.trim().is_empty() {
return Err(ConfigValidationError::invalid_field(format!(
"MCP server '{name}': external_auth.header must not be empty."
)));
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemaDefinition {
pub file: String,
pub table: String,
pub required_columns: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthRequirement {
pub required: bool,
pub scopes: Vec<Permission>,
pub audience: JwtAudience,
pub client_id: Option<ClientId>,
#[serde(default)]
pub ema: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
pub auto_build: bool,
pub build_timeout: u64,
pub health_check_timeout: u64,
#[serde(default = "default_base_port")]
pub base_port: u16,
#[serde(default = "default_working_dir")]
pub working_dir: String,
}
const fn default_base_port() -> u16 {
5000
}
fn default_working_dir() -> String {
"/app".to_owned()
}