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