pub use super::connector::{ConnectorConfig, ConnectorGrant, ConnectorIdentity};
pub use super::external_auth::ExternalAuth;
use super::external_auth::validate_external_auth;
pub use super::toolset::{ResolvedAccess, ToolAccess, ToolsetRef};
use crate::ai::ToolModelConfig;
use crate::auth::{JwtAudience, Permission};
use crate::errors::ServicesValidationError;
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>,
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub arguments: serde_json::Map<String, serde_json::Value>, #[serde(default, skip_serializing_if = "Option::is_none")]
pub access: Option<ToolAccess>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_policy: Option<crate::bridge::ids::ToolPolicy>,
}
#[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)]
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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default = "default_removable", skip_serializing_if = "is_removable")]
pub removable: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub toolsets: Vec<ToolsetRef>,
}
const fn default_removable() -> bool {
true
}
const fn is_removable(removable: &bool) -> bool {
*removable
}
impl Deployment {
pub fn validate(&self, name: &str) -> Result<(), ServicesValidationError> {
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)?;
}
self.validate_toolsets(name)
}
fn validate_internal(&self, name: &str) -> Result<(), ServicesValidationError> {
if self.binary.as_deref().is_none_or(|b| b.trim().is_empty()) || self.port.is_none() {
return Err(ServicesValidationError::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(ServicesValidationError::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(ServicesValidationError::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<(), ServicesValidationError> {
if self
.endpoint
.as_deref()
.is_none_or(|ep| ep.trim().is_empty())
{
return Err(ServicesValidationError::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(ServicesValidationError::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<(), ServicesValidationError> {
if connector.adapter != "generic"
|| !self
.endpoint
.as_deref()
.is_some_and(|endpoint| endpoint.starts_with("https://"))
{
return Err(ServicesValidationError::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(ServicesValidationError::invalid_field(format!(
"MCP server '{name}': connector client secret requires a client ID"
)));
}
connector.validate(name)
}
}
#[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()
}