1use crate::ai::ToolModelConfig;
10use crate::auth::{JwtAudience, Permission};
11use crate::errors::ConfigValidationError;
12use crate::mcp::capabilities::ToolVisibility;
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use systemprompt_identifiers::ClientId;
16
17#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
18pub enum McpServerType {
19 #[default]
20 #[serde(rename = "internal")]
21 Internal,
22 #[serde(rename = "external")]
23 External,
24}
25
26impl McpServerType {
27 pub const fn as_str(&self) -> &'static str {
28 match self {
29 Self::Internal => "internal",
30 Self::External => "external",
31 }
32 }
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, Default)]
36pub struct ToolUiConfig {
37 #[serde(default = "default_resource_uri_template")]
38 pub resource_uri_template: String,
39 #[serde(default = "default_visibility_enum")]
40 pub visibility: Vec<ToolVisibility>,
41}
42
43fn default_resource_uri_template() -> String {
44 "ui://systemprompt/{artifact_id}".to_owned()
45}
46
47fn default_visibility_enum() -> Vec<ToolVisibility> {
48 vec![ToolVisibility::Model, ToolVisibility::App]
49}
50
51impl ToolUiConfig {
52 pub fn new() -> Self {
53 Self::default()
54 }
55
56 pub fn with_template(mut self, template: impl Into<String>) -> Self {
57 self.resource_uri_template = template.into();
58 self
59 }
60
61 pub fn model_only(mut self) -> Self {
62 self.visibility = vec![ToolVisibility::Model];
63 self
64 }
65
66 pub fn model_and_app(mut self) -> Self {
67 self.visibility = vec![ToolVisibility::Model, ToolVisibility::App];
68 self
69 }
70
71 pub fn to_meta_json(&self) -> serde_json::Value {
72 serde_json::json!({
73 "ui": {
74 "resourceUri": self.resource_uri_template,
75 "visibility": self.visibility
76 }
77 })
78 }
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, Default)]
82pub struct ToolMetadata {
83 #[serde(default)]
84 pub terminal_on_success: bool,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 pub model_config: Option<ToolModelConfig>,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 pub ui: Option<ToolUiConfig>,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct DeploymentConfig {
93 pub deployments: HashMap<String, Deployment>,
94 pub settings: Settings,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct Deployment {
99 #[serde(default, alias = "type")]
100 pub server_type: McpServerType,
101 pub binary: String,
102 pub package: Option<String>,
103 pub port: u16,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub endpoint: Option<String>,
106 pub enabled: bool,
107 pub display_in_web: bool,
108 #[serde(default)]
109 pub dev_only: bool,
110 #[serde(default)]
111 pub schemas: Vec<SchemaDefinition>,
112 pub oauth: OAuthRequirement,
113 #[serde(default)]
114 pub tools: HashMap<String, ToolMetadata>,
115 #[serde(skip_serializing_if = "Option::is_none")]
116 pub model_config: Option<ToolModelConfig>,
117 #[serde(default)]
118 pub env_vars: Vec<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub external_auth: Option<ExternalAuth>,
121 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
122 pub headers: HashMap<String, String>,
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 {
155 if self.scheme.trim().is_empty() {
156 bearer.to_owned()
157 } else {
158 format!("{} {bearer}", self.scheme)
159 }
160 }
161}
162
163impl Deployment {
164 pub fn validate(&self, name: &str) -> Result<(), ConfigValidationError> {
165 if matches!(self.server_type, McpServerType::Internal) {
166 if let Some(ep) = self.endpoint.as_deref()
167 && (ep.starts_with("http://") || ep.starts_with("https://"))
168 {
169 return Err(ConfigValidationError::invalid_field(format!(
170 "MCP server '{name}': endpoint must be a relative path (e.g. \
171 /api/v1/mcp/{name}/mcp) or omitted; the host is derived from \
172 server.api_external_url. Remove the scheme+host prefix."
173 )));
174 }
175 if self.external_auth.is_some() || !self.headers.is_empty() {
176 return Err(ConfigValidationError::invalid_field(format!(
177 "MCP server '{name}': external_auth and headers are only valid on \
178 external servers; internal servers are reached through the gateway \
179 with the systemprompt credential."
180 )));
181 }
182 }
183
184 if let Some(ext) = self.external_auth.as_ref() {
185 if ext.token_endpoint.starts_with("http://")
186 || ext.token_endpoint.starts_with("https://")
187 {
188 return Err(ConfigValidationError::invalid_field(format!(
189 "MCP server '{name}': external_auth.token_endpoint must be a relative \
190 path (e.g. /api/public/<provider>/token); the host is derived from \
191 server.api_external_url. Remove the scheme+host prefix."
192 )));
193 }
194 if !ext.token_endpoint.starts_with('/') {
195 return Err(ConfigValidationError::invalid_field(format!(
196 "MCP server '{name}': external_auth.token_endpoint must be an absolute \
197 path beginning with '/'."
198 )));
199 }
200 if ext.header.trim().is_empty() {
201 return Err(ConfigValidationError::invalid_field(format!(
202 "MCP server '{name}': external_auth.header must not be empty."
203 )));
204 }
205 }
206
207 Ok(())
208 }
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct SchemaDefinition {
213 pub file: String,
214 pub table: String,
215 pub required_columns: Vec<String>,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct OAuthRequirement {
220 pub required: bool,
221 pub scopes: Vec<Permission>,
222 pub audience: JwtAudience,
223 pub client_id: Option<ClientId>,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct Settings {
228 pub auto_build: bool,
229 pub build_timeout: u64,
230 pub health_check_timeout: u64,
231 #[serde(default = "default_base_port")]
232 pub base_port: u16,
233 #[serde(default = "default_working_dir")]
234 pub working_dir: String,
235}
236
237const fn default_base_port() -> u16 {
238 5000
239}
240
241fn default_working_dir() -> String {
242 "/app".to_owned()
243}