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