1pub use super::connector::{ConnectorConfig, ConnectorIdentity};
13use crate::ai::ToolModelConfig;
14use crate::auth::{JwtAudience, Permission};
15use crate::errors::ConfigValidationError;
16use crate::mcp::capabilities::ToolVisibility;
17use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19use systemprompt_identifiers::ClientId;
20
21#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
22pub enum McpServerType {
23 #[default]
24 #[serde(rename = "internal")]
25 Internal,
26 #[serde(rename = "external")]
27 External,
28}
29
30impl McpServerType {
31 pub const fn as_str(&self) -> &'static str {
32 match self {
33 Self::Internal => "internal",
34 Self::External => "external",
35 }
36 }
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, Default)]
40pub struct ToolUiConfig {
41 #[serde(default = "default_resource_uri_template")]
42 pub resource_uri_template: String,
43 #[serde(default = "default_visibility_enum")]
44 pub visibility: Vec<ToolVisibility>,
45}
46
47fn default_resource_uri_template() -> String {
48 "ui://systemprompt/{artifact_id}".to_owned()
49}
50
51fn default_visibility_enum() -> Vec<ToolVisibility> {
52 vec![ToolVisibility::Model, ToolVisibility::App]
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, Default)]
56pub struct ToolMetadata {
57 #[serde(default)]
58 pub terminal_on_success: bool,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub model_config: Option<ToolModelConfig>,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub ui: Option<ToolUiConfig>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct DeploymentConfig {
67 pub deployments: HashMap<String, Deployment>,
68 pub settings: Settings,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct Deployment {
82 #[serde(default, alias = "type")]
83 pub server_type: McpServerType,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub binary: Option<String>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub package: Option<String>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub port: Option<u16>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub endpoint: Option<String>,
92 pub enabled: bool,
93 pub display_in_web: bool,
94 #[serde(default)]
95 pub dev_only: bool,
96 #[serde(default)]
97 pub schemas: Vec<SchemaDefinition>,
98 pub oauth: OAuthRequirement,
99 #[serde(default)]
100 pub tools: HashMap<String, ToolMetadata>,
101 #[serde(skip_serializing_if = "Option::is_none")]
102 pub model_config: Option<ToolModelConfig>,
103 #[serde(default)]
104 pub env_vars: Vec<String>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub external_auth: Option<ExternalAuth>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub connector: Option<ConnectorConfig>,
109 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
110 pub headers: HashMap<String, String>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub tool_policy: Option<crate::bridge::ids::ToolPolicy>,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct ExternalAuth {
125 pub token_endpoint: String,
126 #[serde(default = "default_auth_header")]
127 pub header: String,
128 #[serde(default = "default_auth_scheme")]
129 pub scheme: String,
130}
131
132fn default_auth_header() -> String {
133 "Authorization".to_owned()
134}
135
136fn default_auth_scheme() -> String {
137 "Bearer".to_owned()
138}
139
140impl ExternalAuth {
141 pub fn header_value(&self, bearer: &str) -> String {
142 if self.scheme.trim().is_empty() {
143 bearer.to_owned()
144 } else {
145 format!("{} {bearer}", self.scheme)
146 }
147 }
148}
149
150impl Deployment {
151 pub fn validate(&self, name: &str) -> Result<(), ConfigValidationError> {
152 match self.server_type {
153 McpServerType::Internal => self.validate_internal(name)?,
154 McpServerType::External => self.validate_external(name)?,
155 }
156 if let Some(connector) = self.connector.as_ref() {
157 self.validate_connector(name, connector)?;
158 }
159 if let Some(ext) = self.external_auth.as_ref() {
160 validate_external_auth(name, ext)?;
161 }
162 Ok(())
163 }
164
165 fn validate_internal(&self, name: &str) -> Result<(), ConfigValidationError> {
166 if self.binary.as_deref().is_none_or(|b| b.trim().is_empty()) || self.port.is_none() {
167 return Err(ConfigValidationError::invalid_field(format!(
168 "MCP server '{name}': internal servers require a binary and a port."
169 )));
170 }
171 if let Some(ep) = self.endpoint.as_deref()
172 && (ep.starts_with("http://") || ep.starts_with("https://"))
173 {
174 return Err(ConfigValidationError::invalid_field(format!(
175 "MCP server '{name}': endpoint must be a relative path (e.g. \
176 /api/v1/mcp/{name}/mcp) or omitted; the host is derived from \
177 server.api_external_url. Remove the scheme+host prefix."
178 )));
179 }
180 if self.external_auth.is_some() || self.connector.is_some() || !self.headers.is_empty() {
181 return Err(ConfigValidationError::invalid_field(format!(
182 "MCP server '{name}': external_auth and headers are only valid on \
183 external servers; internal servers are reached through the gateway \
184 with the systemprompt credential."
185 )));
186 }
187 Ok(())
188 }
189
190 fn validate_external(&self, name: &str) -> Result<(), ConfigValidationError> {
191 if self
192 .endpoint
193 .as_deref()
194 .is_none_or(|ep| ep.trim().is_empty())
195 {
196 return Err(ConfigValidationError::invalid_field(format!(
197 "MCP server '{name}': external servers require an endpoint."
198 )));
199 }
200 if self.binary.is_some() || self.package.is_some() || self.port.is_some() {
201 return Err(ConfigValidationError::invalid_field(format!(
202 "MCP server '{name}': binary, package and port are only valid on internal \
203 servers; an external server is reached at its endpoint and is never \
204 bound locally. Remove them."
205 )));
206 }
207 Ok(())
208 }
209
210 fn validate_connector(
211 &self,
212 name: &str,
213 connector: &ConnectorConfig,
214 ) -> Result<(), ConfigValidationError> {
215 if connector.adapter != "generic"
216 || !self
217 .endpoint
218 .as_deref()
219 .is_some_and(|endpoint| endpoint.starts_with("https://"))
220 {
221 return Err(ConfigValidationError::invalid_field(format!(
222 "MCP server '{name}': generic connector requires an HTTPS resource"
223 )));
224 }
225 if connector.client_secret.is_some() && connector.client_id_secret.is_none() {
226 return Err(ConfigValidationError::invalid_field(format!(
227 "MCP server '{name}': connector client secret requires a client ID"
228 )));
229 }
230 connector.validate(name)
231 }
232}
233
234fn validate_external_auth(name: &str, ext: &ExternalAuth) -> Result<(), ConfigValidationError> {
235 if ext.token_endpoint.starts_with("http://") || ext.token_endpoint.starts_with("https://") {
236 return Err(ConfigValidationError::invalid_field(format!(
237 "MCP server '{name}': external_auth.token_endpoint must be a relative \
238 path (e.g. /api/public/<provider>/token); the host is derived from \
239 server.api_external_url. Remove the scheme+host prefix."
240 )));
241 }
242 if !ext.token_endpoint.starts_with('/') {
243 return Err(ConfigValidationError::invalid_field(format!(
244 "MCP server '{name}': external_auth.token_endpoint must be an absolute \
245 path beginning with '/'."
246 )));
247 }
248 if ext.header.trim().is_empty() {
249 return Err(ConfigValidationError::invalid_field(format!(
250 "MCP server '{name}': external_auth.header must not be empty."
251 )));
252 }
253 Ok(())
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct SchemaDefinition {
258 pub file: String,
259 pub table: String,
260 pub required_columns: Vec<String>,
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct OAuthRequirement {
265 pub required: bool,
266 pub scopes: Vec<Permission>,
267 pub audience: JwtAudience,
268 pub client_id: Option<ClientId>,
269 #[serde(default)]
270 pub ema: bool,
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct Settings {
275 pub auto_build: bool,
276 pub build_timeout: u64,
277 pub health_check_timeout: u64,
278 #[serde(default = "default_base_port")]
279 pub base_port: u16,
280 #[serde(default = "default_working_dir")]
281 pub working_dir: String,
282}
283
284const fn default_base_port() -> u16 {
285 5000
286}
287
288fn default_working_dir() -> String {
289 "/app".to_owned()
290}