Skip to main content

systemprompt_models/mcp/
deployment.rs

1//! MCP server deployment configuration.
2//!
3//! [`DeploymentConfig`] is the top-level shape loaded from MCP service YAML:
4//! a map of named [`Deployment`]s plus global [`Settings`]. Each deployment
5//! declares its [`McpServerType`], OAuth requirement, schemas, and per-tool
6//! [`ToolMetadata`]. Internal-server endpoints are validated relative by
7//! [`Deployment::validate`].
8
9use 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/// Per-user bearer resolution for an `external` MCP server.
126///
127/// The MCP gateway exposes no token vault of its own; instead an extension
128/// banks the calling user's third-party token and serves it from
129/// `token_endpoint`. At tool-call time core `GET`s that accessor with the
130/// user's systemprompt JWT and injects the returned bearer onto `header` (as
131/// `{scheme} {token}`), replacing the systemprompt credential so nothing
132/// internal reaches the third party.
133#[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    /// The value to send on [`Self::header`] for `bearer`: `"{scheme}
152    /// {token}"`, or the raw token when `scheme` is empty (providers that
153    /// expect a bare credential, e.g. an `X-Api-Key`).
154    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}