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
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/// Per-user bearer resolution for an `external` MCP server.
96///
97/// The MCP gateway exposes no token vault of its own; instead an extension
98/// banks the calling user's third-party token and serves it from
99/// `token_endpoint`. At tool-call time core `GET`s that accessor with the
100/// user's systemprompt JWT and injects the returned bearer onto `header` (as
101/// `{scheme} {token}`), replacing the systemprompt credential so nothing
102/// internal reaches the third party.
103#[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    /// The value to send on [`Self::header`] for `bearer`: `"{scheme}
122    /// {token}"`, or the raw token when `scheme` is empty (providers that
123    /// expect a bare credential, e.g. an `X-Api-Key`).
124    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}