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/// Per-tool settings the instance layers over a server's own tool contract.
56///
57/// `arguments` are values the instance fixes for an external tool: the gateway
58/// writes them into every `tools/call` for that tool, replacing whatever the
59/// client sent. They exist for parameters that identify infrastructure rather
60/// than intent — the Agent Search `servingConfig`, a tenant, a project — which
61/// a model cannot know and, left to guess, invents (`projects/*`). The skill
62/// text stops carrying them and the connector works from a bare prompt.
63#[derive(Debug, Clone, Serialize, Deserialize, Default)]
64pub struct ToolMetadata {
65    #[serde(default)]
66    pub terminal_on_success: bool,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub model_config: Option<ToolModelConfig>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub ui: Option<ToolUiConfig>,
71    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
72    pub arguments: serde_json::Map<String, serde_json::Value>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct DeploymentConfig {
77    pub deployments: HashMap<String, Deployment>,
78    pub settings: Settings,
79}
80
81/// One MCP server as declared in the services tree.
82///
83/// `tool_policy` is the default decision a bridge-managed client applies to
84/// every tool this server exposes and is required on every enabled server:
85/// a server that declares none has no decision the bridge can enforce, so it
86/// is withheld from the signed bridge manifest and startup validation reports
87/// it as an error. `allow` skips the client's per-call prompt (the governance
88/// chain already judges every call); `prompt` or `deny` opt the server back
89/// into the client's confirmation or block it.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct Deployment {
92    #[serde(default, alias = "type")]
93    pub server_type: McpServerType,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub binary: Option<String>,
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub package: Option<String>,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub port: Option<u16>,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub endpoint: Option<String>,
102    pub enabled: bool,
103    pub display_in_web: bool,
104    #[serde(default)]
105    pub dev_only: bool,
106    #[serde(default)]
107    pub schemas: Vec<SchemaDefinition>,
108    pub oauth: OAuthRequirement,
109    #[serde(default)]
110    pub tools: HashMap<String, ToolMetadata>,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub model_config: Option<ToolModelConfig>,
113    #[serde(default)]
114    pub env_vars: Vec<String>,
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub external_auth: Option<ExternalAuth>,
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub connector: Option<ConnectorConfig>,
119    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
120    pub headers: HashMap<String, String>,
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub tool_policy: Option<crate::bridge::ids::ToolPolicy>,
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    pub fn header_value(&self, bearer: &str) -> String {
152        if self.scheme.trim().is_empty() {
153            bearer.to_owned()
154        } else {
155            format!("{} {bearer}", self.scheme)
156        }
157    }
158}
159
160impl Deployment {
161    pub fn validate(&self, name: &str) -> Result<(), ConfigValidationError> {
162        match self.server_type {
163            McpServerType::Internal => self.validate_internal(name)?,
164            McpServerType::External => self.validate_external(name)?,
165        }
166        if let Some(connector) = self.connector.as_ref() {
167            self.validate_connector(name, connector)?;
168        }
169        if let Some(ext) = self.external_auth.as_ref() {
170            validate_external_auth(name, ext)?;
171        }
172        Ok(())
173    }
174
175    fn validate_internal(&self, name: &str) -> Result<(), ConfigValidationError> {
176        if self.binary.as_deref().is_none_or(|b| b.trim().is_empty()) || self.port.is_none() {
177            return Err(ConfigValidationError::invalid_field(format!(
178                "MCP server '{name}': internal servers require a binary and a port."
179            )));
180        }
181        if let Some(ep) = self.endpoint.as_deref()
182            && (ep.starts_with("http://") || ep.starts_with("https://"))
183        {
184            return Err(ConfigValidationError::invalid_field(format!(
185                "MCP server '{name}': endpoint must be a relative path (e.g. \
186                     /api/v1/mcp/{name}/mcp) or omitted; the host is derived from \
187                     server.api_external_url. Remove the scheme+host prefix."
188            )));
189        }
190        if self.external_auth.is_some() || self.connector.is_some() || !self.headers.is_empty() {
191            return Err(ConfigValidationError::invalid_field(format!(
192                "MCP server '{name}': external_auth and headers are only valid on \
193                     external servers; internal servers are reached through the gateway \
194                     with the systemprompt credential."
195            )));
196        }
197        Ok(())
198    }
199
200    fn validate_external(&self, name: &str) -> Result<(), ConfigValidationError> {
201        if self
202            .endpoint
203            .as_deref()
204            .is_none_or(|ep| ep.trim().is_empty())
205        {
206            return Err(ConfigValidationError::invalid_field(format!(
207                "MCP server '{name}': external servers require an endpoint."
208            )));
209        }
210        if self.binary.is_some() || self.package.is_some() || self.port.is_some() {
211            return Err(ConfigValidationError::invalid_field(format!(
212                "MCP server '{name}': binary, package and port are only valid on internal \
213                     servers; an external server is reached at its endpoint and is never \
214                     bound locally. Remove them."
215            )));
216        }
217        Ok(())
218    }
219
220    fn validate_connector(
221        &self,
222        name: &str,
223        connector: &ConnectorConfig,
224    ) -> Result<(), ConfigValidationError> {
225        if connector.adapter != "generic"
226            || !self
227                .endpoint
228                .as_deref()
229                .is_some_and(|endpoint| endpoint.starts_with("https://"))
230        {
231            return Err(ConfigValidationError::invalid_field(format!(
232                "MCP server '{name}': generic connector requires an HTTPS resource"
233            )));
234        }
235        if connector.client_secret.is_some() && connector.client_id_secret.is_none() {
236            return Err(ConfigValidationError::invalid_field(format!(
237                "MCP server '{name}': connector client secret requires a client ID"
238            )));
239        }
240        connector.validate(name)
241    }
242}
243
244fn validate_external_auth(name: &str, ext: &ExternalAuth) -> Result<(), ConfigValidationError> {
245    if ext.token_endpoint.starts_with("http://") || ext.token_endpoint.starts_with("https://") {
246        return Err(ConfigValidationError::invalid_field(format!(
247            "MCP server '{name}': external_auth.token_endpoint must be a relative \
248                 path (e.g. /api/public/<provider>/token); the host is derived from \
249                 server.api_external_url. Remove the scheme+host prefix."
250        )));
251    }
252    if !ext.token_endpoint.starts_with('/') {
253        return Err(ConfigValidationError::invalid_field(format!(
254            "MCP server '{name}': external_auth.token_endpoint must be an absolute \
255                 path beginning with '/'."
256        )));
257    }
258    if ext.header.trim().is_empty() {
259        return Err(ConfigValidationError::invalid_field(format!(
260            "MCP server '{name}': external_auth.header must not be empty."
261        )));
262    }
263    Ok(())
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct SchemaDefinition {
268    pub file: String,
269    pub table: String,
270    pub required_columns: Vec<String>,
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct OAuthRequirement {
275    pub required: bool,
276    pub scopes: Vec<Permission>,
277    pub audience: JwtAudience,
278    pub client_id: Option<ClientId>,
279    #[serde(default)]
280    pub ema: bool,
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct Settings {
285    pub auto_build: bool,
286    pub build_timeout: u64,
287    pub health_check_timeout: u64,
288    #[serde(default = "default_base_port")]
289    pub base_port: u16,
290    #[serde(default = "default_working_dir")]
291    pub working_dir: String,
292}
293
294const fn default_base_port() -> u16 {
295    5000
296}
297
298fn default_working_dir() -> String {
299    "/app".to_owned()
300}