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)]
64pub struct ToolMetadata {
65 #[serde(default)]
66 pub terminal_on_success: bool,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub model_config: Option<ToolModelConfig>,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub ui: Option<ToolUiConfig>,
71 #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
72 pub arguments: serde_json::Map<String, serde_json::Value>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct DeploymentConfig {
77 pub deployments: HashMap<String, Deployment>,
78 pub settings: Settings,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct Deployment {
92 #[serde(default, alias = "type")]
93 pub server_type: McpServerType,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub binary: Option<String>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub package: Option<String>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub port: Option<u16>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub endpoint: Option<String>,
102 pub enabled: bool,
103 pub display_in_web: bool,
104 #[serde(default)]
105 pub dev_only: bool,
106 #[serde(default)]
107 pub schemas: Vec<SchemaDefinition>,
108 pub oauth: OAuthRequirement,
109 #[serde(default)]
110 pub tools: HashMap<String, ToolMetadata>,
111 #[serde(skip_serializing_if = "Option::is_none")]
112 pub model_config: Option<ToolModelConfig>,
113 #[serde(default)]
114 pub env_vars: Vec<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub external_auth: Option<ExternalAuth>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub connector: Option<ConnectorConfig>,
119 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
120 pub headers: HashMap<String, String>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub tool_policy: Option<crate::bridge::ids::ToolPolicy>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct ExternalAuth {
135 pub token_endpoint: String,
136 #[serde(default = "default_auth_header")]
137 pub header: String,
138 #[serde(default = "default_auth_scheme")]
139 pub scheme: String,
140}
141
142fn default_auth_header() -> String {
143 "Authorization".to_owned()
144}
145
146fn default_auth_scheme() -> String {
147 "Bearer".to_owned()
148}
149
150impl ExternalAuth {
151 pub fn header_value(&self, bearer: &str) -> String {
152 if self.scheme.trim().is_empty() {
153 bearer.to_owned()
154 } else {
155 format!("{} {bearer}", self.scheme)
156 }
157 }
158}
159
160impl Deployment {
161 pub fn validate(&self, name: &str) -> Result<(), ConfigValidationError> {
162 match self.server_type {
163 McpServerType::Internal => self.validate_internal(name)?,
164 McpServerType::External => self.validate_external(name)?,
165 }
166 if let Some(connector) = self.connector.as_ref() {
167 self.validate_connector(name, connector)?;
168 }
169 if let Some(ext) = self.external_auth.as_ref() {
170 validate_external_auth(name, ext)?;
171 }
172 Ok(())
173 }
174
175 fn validate_internal(&self, name: &str) -> Result<(), ConfigValidationError> {
176 if self.binary.as_deref().is_none_or(|b| b.trim().is_empty()) || self.port.is_none() {
177 return Err(ConfigValidationError::invalid_field(format!(
178 "MCP server '{name}': internal servers require a binary and a port."
179 )));
180 }
181 if let Some(ep) = self.endpoint.as_deref()
182 && (ep.starts_with("http://") || ep.starts_with("https://"))
183 {
184 return Err(ConfigValidationError::invalid_field(format!(
185 "MCP server '{name}': endpoint must be a relative path (e.g. \
186 /api/v1/mcp/{name}/mcp) or omitted; the host is derived from \
187 server.api_external_url. Remove the scheme+host prefix."
188 )));
189 }
190 if self.external_auth.is_some() || self.connector.is_some() || !self.headers.is_empty() {
191 return Err(ConfigValidationError::invalid_field(format!(
192 "MCP server '{name}': external_auth and headers are only valid on \
193 external servers; internal servers are reached through the gateway \
194 with the systemprompt credential."
195 )));
196 }
197 Ok(())
198 }
199
200 fn validate_external(&self, name: &str) -> Result<(), ConfigValidationError> {
201 if self
202 .endpoint
203 .as_deref()
204 .is_none_or(|ep| ep.trim().is_empty())
205 {
206 return Err(ConfigValidationError::invalid_field(format!(
207 "MCP server '{name}': external servers require an endpoint."
208 )));
209 }
210 if self.binary.is_some() || self.package.is_some() || self.port.is_some() {
211 return Err(ConfigValidationError::invalid_field(format!(
212 "MCP server '{name}': binary, package and port are only valid on internal \
213 servers; an external server is reached at its endpoint and is never \
214 bound locally. Remove them."
215 )));
216 }
217 Ok(())
218 }
219
220 fn validate_connector(
221 &self,
222 name: &str,
223 connector: &ConnectorConfig,
224 ) -> Result<(), ConfigValidationError> {
225 if connector.adapter != "generic"
226 || !self
227 .endpoint
228 .as_deref()
229 .is_some_and(|endpoint| endpoint.starts_with("https://"))
230 {
231 return Err(ConfigValidationError::invalid_field(format!(
232 "MCP server '{name}': generic connector requires an HTTPS resource"
233 )));
234 }
235 if connector.client_secret.is_some() && connector.client_id_secret.is_none() {
236 return Err(ConfigValidationError::invalid_field(format!(
237 "MCP server '{name}': connector client secret requires a client ID"
238 )));
239 }
240 connector.validate(name)
241 }
242}
243
244fn validate_external_auth(name: &str, ext: &ExternalAuth) -> Result<(), ConfigValidationError> {
245 if ext.token_endpoint.starts_with("http://") || ext.token_endpoint.starts_with("https://") {
246 return Err(ConfigValidationError::invalid_field(format!(
247 "MCP server '{name}': external_auth.token_endpoint must be a relative \
248 path (e.g. /api/public/<provider>/token); the host is derived from \
249 server.api_external_url. Remove the scheme+host prefix."
250 )));
251 }
252 if !ext.token_endpoint.starts_with('/') {
253 return Err(ConfigValidationError::invalid_field(format!(
254 "MCP server '{name}': external_auth.token_endpoint must be an absolute \
255 path beginning with '/'."
256 )));
257 }
258 if ext.header.trim().is_empty() {
259 return Err(ConfigValidationError::invalid_field(format!(
260 "MCP server '{name}': external_auth.header must not be empty."
261 )));
262 }
263 Ok(())
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct SchemaDefinition {
268 pub file: String,
269 pub table: String,
270 pub required_columns: Vec<String>,
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct OAuthRequirement {
275 pub required: bool,
276 pub scopes: Vec<Permission>,
277 pub audience: JwtAudience,
278 pub client_id: Option<ClientId>,
279 #[serde(default)]
280 pub ema: bool,
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct Settings {
285 pub auto_build: bool,
286 pub build_timeout: u64,
287 pub health_check_timeout: u64,
288 #[serde(default = "default_base_port")]
289 pub base_port: u16,
290 #[serde(default = "default_working_dir")]
291 pub working_dir: String,
292}
293
294const fn default_base_port() -> u16 {
295 5000
296}
297
298fn default_working_dir() -> String {
299 "/app".to_owned()
300}