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