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//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use 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/// One MCP server as declared in the services tree.
71///
72/// `tool_policy` is the default decision a bridge-managed client applies to
73/// every tool this server exposes and is required on every enabled server:
74/// a server that declares none has no decision the bridge can enforce, so it
75/// is withheld from the signed bridge manifest and startup validation reports
76/// it as an error. `allow` skips the client's per-call prompt (the governance
77/// chain already judges every call); `prompt` or `deny` opt the server back
78/// into the client's confirmation or block it.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct Deployment {
81    #[serde(default, alias = "type")]
82    pub server_type: McpServerType,
83    pub binary: String,
84    pub package: Option<String>,
85    pub port: u16,
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub endpoint: Option<String>,
88    pub enabled: bool,
89    pub display_in_web: bool,
90    #[serde(default)]
91    pub dev_only: bool,
92    #[serde(default)]
93    pub schemas: Vec<SchemaDefinition>,
94    pub oauth: OAuthRequirement,
95    #[serde(default)]
96    pub tools: HashMap<String, ToolMetadata>,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub model_config: Option<ToolModelConfig>,
99    #[serde(default)]
100    pub env_vars: Vec<String>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub external_auth: Option<ExternalAuth>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub connector: Option<ConnectorConfig>,
105    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
106    pub headers: HashMap<String, String>,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub tool_policy: Option<crate::bridge::ids::ToolPolicy>,
109}
110
111/// Per-user bearer resolution for an `external` MCP server.
112///
113/// The MCP gateway exposes no token vault of its own; instead an extension
114/// banks the calling user's third-party token and serves it from
115/// `token_endpoint`. At tool-call time core `GET`s that accessor with the
116/// user's systemprompt JWT and injects the returned bearer onto `header` (as
117/// `{scheme} {token}`), replacing the systemprompt credential so nothing
118/// internal reaches the third party.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct ExternalAuth {
121    pub token_endpoint: String,
122    #[serde(default = "default_auth_header")]
123    pub header: String,
124    #[serde(default = "default_auth_scheme")]
125    pub scheme: String,
126}
127
128fn default_auth_header() -> String {
129    "Authorization".to_owned()
130}
131
132fn default_auth_scheme() -> String {
133    "Bearer".to_owned()
134}
135
136impl ExternalAuth {
137    pub fn header_value(&self, bearer: &str) -> String {
138        if self.scheme.trim().is_empty() {
139            bearer.to_owned()
140        } else {
141            format!("{} {bearer}", self.scheme)
142        }
143    }
144}
145
146impl Deployment {
147    pub fn validate(&self, name: &str) -> Result<(), ConfigValidationError> {
148        if matches!(self.server_type, McpServerType::Internal) {
149            if let Some(ep) = self.endpoint.as_deref()
150                && (ep.starts_with("http://") || ep.starts_with("https://"))
151            {
152                return Err(ConfigValidationError::invalid_field(format!(
153                    "MCP server '{name}': endpoint must be a relative path (e.g. \
154                         /api/v1/mcp/{name}/mcp) or omitted; the host is derived from \
155                         server.api_external_url. Remove the scheme+host prefix."
156                )));
157            }
158            if self.external_auth.is_some() || self.connector.is_some() || !self.headers.is_empty()
159            {
160                return Err(ConfigValidationError::invalid_field(format!(
161                    "MCP server '{name}': external_auth and headers are only valid on \
162                         external servers; internal servers are reached through the gateway \
163                         with the systemprompt credential."
164                )));
165            }
166        }
167
168        if let Some(connector) = self.connector.as_ref() {
169            if connector.adapter != "generic"
170                || !self
171                    .endpoint
172                    .as_deref()
173                    .is_some_and(|endpoint| endpoint.starts_with("https://"))
174            {
175                return Err(ConfigValidationError::invalid_field(format!(
176                    "MCP server '{name}': generic connector requires an HTTPS resource"
177                )));
178            }
179            if connector.client_secret.is_some() && connector.client_id_secret.is_none() {
180                return Err(ConfigValidationError::invalid_field(format!(
181                    "MCP server '{name}': connector client secret requires a client ID"
182                )));
183            }
184        }
185        if let Some(ext) = self.external_auth.as_ref() {
186            if ext.token_endpoint.starts_with("http://")
187                || ext.token_endpoint.starts_with("https://")
188            {
189                return Err(ConfigValidationError::invalid_field(format!(
190                    "MCP server '{name}': external_auth.token_endpoint must be a relative \
191                         path (e.g. /api/public/<provider>/token); the host is derived from \
192                         server.api_external_url. Remove the scheme+host prefix."
193                )));
194            }
195            if !ext.token_endpoint.starts_with('/') {
196                return Err(ConfigValidationError::invalid_field(format!(
197                    "MCP server '{name}': external_auth.token_endpoint must be an absolute \
198                         path beginning with '/'."
199                )));
200            }
201            if ext.header.trim().is_empty() {
202                return Err(ConfigValidationError::invalid_field(format!(
203                    "MCP server '{name}': external_auth.header must not be empty."
204                )));
205            }
206        }
207
208        Ok(())
209    }
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct SchemaDefinition {
214    pub file: String,
215    pub table: String,
216    pub required_columns: Vec<String>,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct OAuthRequirement {
221    pub required: bool,
222    pub scopes: Vec<Permission>,
223    pub audience: JwtAudience,
224    pub client_id: Option<ClientId>,
225    #[serde(default)]
226    pub ema: bool,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct Settings {
231    pub auto_build: bool,
232    pub build_timeout: u64,
233    pub health_check_timeout: u64,
234    #[serde(default = "default_base_port")]
235    pub base_port: u16,
236    #[serde(default = "default_working_dir")]
237    pub working_dir: String,
238}
239
240const fn default_base_port() -> u16 {
241    5000
242}
243
244fn default_working_dir() -> String {
245    "/app".to_owned()
246}
247
248/// Outbound personal-account OAuth settings, separate from inbound MCP access.
249#[derive(Debug, Clone, Serialize, Deserialize)]
250#[serde(deny_unknown_fields)]
251pub struct ConnectorConfig {
252    #[serde(default = "generic_adapter")]
253    pub adapter: String,
254    #[serde(default)]
255    pub scopes: Vec<String>,
256    #[serde(default)]
257    pub authorization_origins: Vec<String>,
258    #[serde(default)]
259    pub client_id_secret: Option<String>,
260    #[serde(default)]
261    pub client_secret: Option<String>,
262}
263fn generic_adapter() -> String {
264    "generic".to_owned()
265}