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)]
71pub struct Deployment {
72 #[serde(default, alias = "type")]
73 pub server_type: McpServerType,
74 pub binary: String,
75 pub package: Option<String>,
76 pub port: u16,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub endpoint: Option<String>,
79 pub enabled: bool,
80 pub display_in_web: bool,
81 #[serde(default)]
82 pub dev_only: bool,
83 #[serde(default)]
84 pub schemas: Vec<SchemaDefinition>,
85 pub oauth: OAuthRequirement,
86 #[serde(default)]
87 pub tools: HashMap<String, ToolMetadata>,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 pub model_config: Option<ToolModelConfig>,
90 #[serde(default)]
91 pub env_vars: Vec<String>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub external_auth: Option<ExternalAuth>,
94 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
95 pub headers: HashMap<String, String>,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct ExternalAuth {
108 pub token_endpoint: String,
109 #[serde(default = "default_auth_header")]
110 pub header: String,
111 #[serde(default = "default_auth_scheme")]
112 pub scheme: String,
113}
114
115fn default_auth_header() -> String {
116 "Authorization".to_owned()
117}
118
119fn default_auth_scheme() -> String {
120 "Bearer".to_owned()
121}
122
123impl ExternalAuth {
124 pub fn header_value(&self, bearer: &str) -> String {
128 if self.scheme.trim().is_empty() {
129 bearer.to_owned()
130 } else {
131 format!("{} {bearer}", self.scheme)
132 }
133 }
134}
135
136impl Deployment {
137 pub fn validate(&self, name: &str) -> Result<(), ConfigValidationError> {
138 if matches!(self.server_type, McpServerType::Internal) {
139 if let Some(ep) = self.endpoint.as_deref()
140 && (ep.starts_with("http://") || ep.starts_with("https://"))
141 {
142 return Err(ConfigValidationError::invalid_field(format!(
143 "MCP server '{name}': endpoint must be a relative path (e.g. \
144 /api/v1/mcp/{name}/mcp) or omitted; the host is derived from \
145 server.api_external_url. Remove the scheme+host prefix."
146 )));
147 }
148 if self.external_auth.is_some() || !self.headers.is_empty() {
149 return Err(ConfigValidationError::invalid_field(format!(
150 "MCP server '{name}': external_auth and headers are only valid on \
151 external servers; internal servers are reached through the gateway \
152 with the systemprompt credential."
153 )));
154 }
155 }
156
157 if let Some(ext) = self.external_auth.as_ref() {
158 if ext.token_endpoint.starts_with("http://")
159 || ext.token_endpoint.starts_with("https://")
160 {
161 return Err(ConfigValidationError::invalid_field(format!(
162 "MCP server '{name}': external_auth.token_endpoint must be a relative \
163 path (e.g. /api/public/<provider>/token); the host is derived from \
164 server.api_external_url. Remove the scheme+host prefix."
165 )));
166 }
167 if !ext.token_endpoint.starts_with('/') {
168 return Err(ConfigValidationError::invalid_field(format!(
169 "MCP server '{name}': external_auth.token_endpoint must be an absolute \
170 path beginning with '/'."
171 )));
172 }
173 if ext.header.trim().is_empty() {
174 return Err(ConfigValidationError::invalid_field(format!(
175 "MCP server '{name}': external_auth.header must not be empty."
176 )));
177 }
178 }
179
180 Ok(())
181 }
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct SchemaDefinition {
186 pub file: String,
187 pub table: String,
188 pub required_columns: Vec<String>,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct OAuthRequirement {
193 pub required: bool,
194 pub scopes: Vec<Permission>,
195 pub audience: JwtAudience,
196 pub client_id: Option<ClientId>,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct Settings {
201 pub auto_build: bool,
202 pub build_timeout: u64,
203 pub health_check_timeout: u64,
204 #[serde(default = "default_base_port")]
205 pub base_port: u16,
206 #[serde(default = "default_working_dir")]
207 pub working_dir: String,
208}
209
210const fn default_base_port() -> u16 {
211 5000
212}
213
214fn default_working_dir() -> String {
215 "/app".to_owned()
216}