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
12pub use super::connector::{ConnectorConfig, ConnectorIdentity};
13use crate::ai::ToolModelConfig;
14use crate::auth::{JwtAudience, Permission};
15use crate::errors::ConfigValidationError;
16use crate::mcp::capabilities::ToolVisibility;
17use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19use systemprompt_identifiers::ClientId;
20
21#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
22pub enum McpServerType {
23    #[default]
24    #[serde(rename = "internal")]
25    Internal,
26    #[serde(rename = "external")]
27    External,
28}
29
30impl McpServerType {
31    pub const fn as_str(&self) -> &'static str {
32        match self {
33            Self::Internal => "internal",
34            Self::External => "external",
35        }
36    }
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, Default)]
40pub struct ToolUiConfig {
41    #[serde(default = "default_resource_uri_template")]
42    pub resource_uri_template: String,
43    #[serde(default = "default_visibility_enum")]
44    pub visibility: Vec<ToolVisibility>,
45}
46
47fn default_resource_uri_template() -> String {
48    "ui://systemprompt/{artifact_id}".to_owned()
49}
50
51fn default_visibility_enum() -> Vec<ToolVisibility> {
52    vec![ToolVisibility::Model, ToolVisibility::App]
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, Default)]
56pub struct ToolMetadata {
57    #[serde(default)]
58    pub terminal_on_success: bool,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub model_config: Option<ToolModelConfig>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub ui: Option<ToolUiConfig>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct DeploymentConfig {
67    pub deployments: HashMap<String, Deployment>,
68    pub settings: Settings,
69}
70
71/// One MCP server as declared in the services tree.
72///
73/// `tool_policy` is the default decision a bridge-managed client applies to
74/// every tool this server exposes and is required on every enabled server:
75/// a server that declares none has no decision the bridge can enforce, so it
76/// is withheld from the signed bridge manifest and startup validation reports
77/// it as an error. `allow` skips the client's per-call prompt (the governance
78/// chain already judges every call); `prompt` or `deny` opt the server back
79/// into the client's confirmation or block it.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct Deployment {
82    #[serde(default, alias = "type")]
83    pub server_type: McpServerType,
84    pub binary: String,
85    pub package: Option<String>,
86    pub port: u16,
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub endpoint: Option<String>,
89    pub enabled: bool,
90    pub display_in_web: bool,
91    #[serde(default)]
92    pub dev_only: bool,
93    #[serde(default)]
94    pub schemas: Vec<SchemaDefinition>,
95    pub oauth: OAuthRequirement,
96    #[serde(default)]
97    pub tools: HashMap<String, ToolMetadata>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub model_config: Option<ToolModelConfig>,
100    #[serde(default)]
101    pub env_vars: Vec<String>,
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub external_auth: Option<ExternalAuth>,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub connector: Option<ConnectorConfig>,
106    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
107    pub headers: HashMap<String, String>,
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub tool_policy: Option<crate::bridge::ids::ToolPolicy>,
110}
111
112/// Per-user bearer resolution for an `external` MCP server.
113///
114/// The MCP gateway exposes no token vault of its own; instead an extension
115/// banks the calling user's third-party token and serves it from
116/// `token_endpoint`. At tool-call time core `GET`s that accessor with the
117/// user's systemprompt JWT and injects the returned bearer onto `header` (as
118/// `{scheme} {token}`), replacing the systemprompt credential so nothing
119/// internal reaches the third party.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct ExternalAuth {
122    pub token_endpoint: String,
123    #[serde(default = "default_auth_header")]
124    pub header: String,
125    #[serde(default = "default_auth_scheme")]
126    pub scheme: String,
127}
128
129fn default_auth_header() -> String {
130    "Authorization".to_owned()
131}
132
133fn default_auth_scheme() -> String {
134    "Bearer".to_owned()
135}
136
137impl ExternalAuth {
138    pub fn header_value(&self, bearer: &str) -> String {
139        if self.scheme.trim().is_empty() {
140            bearer.to_owned()
141        } else {
142            format!("{} {bearer}", self.scheme)
143        }
144    }
145}
146
147impl Deployment {
148    pub fn validate(&self, name: &str) -> Result<(), ConfigValidationError> {
149        if matches!(self.server_type, McpServerType::Internal) {
150            if let Some(ep) = self.endpoint.as_deref()
151                && (ep.starts_with("http://") || ep.starts_with("https://"))
152            {
153                return Err(ConfigValidationError::invalid_field(format!(
154                    "MCP server '{name}': endpoint must be a relative path (e.g. \
155                         /api/v1/mcp/{name}/mcp) or omitted; the host is derived from \
156                         server.api_external_url. Remove the scheme+host prefix."
157                )));
158            }
159            if self.external_auth.is_some() || self.connector.is_some() || !self.headers.is_empty()
160            {
161                return Err(ConfigValidationError::invalid_field(format!(
162                    "MCP server '{name}': external_auth and headers are only valid on \
163                         external servers; internal servers are reached through the gateway \
164                         with the systemprompt credential."
165                )));
166            }
167        }
168
169        if let Some(connector) = self.connector.as_ref() {
170            if connector.adapter != "generic"
171                || !self
172                    .endpoint
173                    .as_deref()
174                    .is_some_and(|endpoint| endpoint.starts_with("https://"))
175            {
176                return Err(ConfigValidationError::invalid_field(format!(
177                    "MCP server '{name}': generic connector requires an HTTPS resource"
178                )));
179            }
180            if connector.client_secret.is_some() && connector.client_id_secret.is_none() {
181                return Err(ConfigValidationError::invalid_field(format!(
182                    "MCP server '{name}': connector client secret requires a client ID"
183                )));
184            }
185            connector.validate(name)?;
186        }
187        if let Some(ext) = self.external_auth.as_ref() {
188            if ext.token_endpoint.starts_with("http://")
189                || ext.token_endpoint.starts_with("https://")
190            {
191                return Err(ConfigValidationError::invalid_field(format!(
192                    "MCP server '{name}': external_auth.token_endpoint must be a relative \
193                         path (e.g. /api/public/<provider>/token); the host is derived from \
194                         server.api_external_url. Remove the scheme+host prefix."
195                )));
196            }
197            if !ext.token_endpoint.starts_with('/') {
198                return Err(ConfigValidationError::invalid_field(format!(
199                    "MCP server '{name}': external_auth.token_endpoint must be an absolute \
200                         path beginning with '/'."
201                )));
202            }
203            if ext.header.trim().is_empty() {
204                return Err(ConfigValidationError::invalid_field(format!(
205                    "MCP server '{name}': external_auth.header must not be empty."
206                )));
207            }
208        }
209
210        Ok(())
211    }
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct SchemaDefinition {
216    pub file: String,
217    pub table: String,
218    pub required_columns: Vec<String>,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct OAuthRequirement {
223    pub required: bool,
224    pub scopes: Vec<Permission>,
225    pub audience: JwtAudience,
226    pub client_id: Option<ClientId>,
227    #[serde(default)]
228    pub ema: bool,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct Settings {
233    pub auto_build: bool,
234    pub build_timeout: u64,
235    pub health_check_timeout: u64,
236    #[serde(default = "default_base_port")]
237    pub base_port: u16,
238    #[serde(default = "default_working_dir")]
239    pub working_dir: String,
240}
241
242const fn default_base_port() -> u16 {
243    5000
244}
245
246fn default_working_dir() -> String {
247    "/app".to_owned()
248}