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, ConnectorGrant, ConnectorIdentity};
13pub use super::external_auth::ExternalAuth;
14use super::external_auth::validate_external_auth;
15pub use super::toolset::{ResolvedAccess, ToolAccess, ToolsetRef};
16use crate::ai::ToolModelConfig;
17use crate::auth::{JwtAudience, Permission};
18use crate::errors::ServicesValidationError;
19use crate::mcp::capabilities::ToolVisibility;
20use serde::{Deserialize, Serialize};
21use std::collections::HashMap;
22use systemprompt_identifiers::ClientId;
23
24#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
25pub enum McpServerType {
26    #[default]
27    #[serde(rename = "internal")]
28    Internal,
29    #[serde(rename = "external")]
30    External,
31}
32
33impl McpServerType {
34    pub const fn as_str(&self) -> &'static str {
35        match self {
36            Self::Internal => "internal",
37            Self::External => "external",
38        }
39    }
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, Default)]
43pub struct ToolUiConfig {
44    #[serde(default = "default_resource_uri_template")]
45    pub resource_uri_template: String,
46    #[serde(default = "default_visibility_enum")]
47    pub visibility: Vec<ToolVisibility>,
48}
49
50fn default_resource_uri_template() -> String {
51    "ui://systemprompt/{artifact_id}".to_owned()
52}
53
54fn default_visibility_enum() -> Vec<ToolVisibility> {
55    vec![ToolVisibility::Model, ToolVisibility::App]
56}
57
58/// Per-tool settings the instance layers over a server's own tool contract.
59///
60/// `arguments` are values the instance fixes for an external tool: the gateway
61/// writes them into every `tools/call` for that tool, replacing whatever the
62/// client sent. They exist for parameters that identify infrastructure rather
63/// than intent — the Agent Search `servingConfig`, a tenant, a project — which
64/// a model cannot know and, left to guess, invents (`projects/*`). The skill
65/// text stops carrying them and the connector works from a bare prompt.
66#[derive(Debug, Clone, Serialize, Deserialize, Default)]
67pub struct ToolMetadata {
68    #[serde(default)]
69    pub terminal_on_success: bool,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub model_config: Option<ToolModelConfig>,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub ui: Option<ToolUiConfig>,
74    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
75    pub arguments: serde_json::Map<String, serde_json::Value>, // JSON: tool-owned MCP call args
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub access: Option<ToolAccess>,
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub tool_policy: Option<crate::bridge::ids::ToolPolicy>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct DeploymentConfig {
84    pub deployments: HashMap<String, Deployment>,
85    pub settings: Settings,
86}
87
88/// One MCP server as declared in the services tree.
89///
90/// `tool_policy` is the default decision a bridge-managed client applies to
91/// every tool this server exposes and is required on every enabled server:
92/// a server that declares none has no decision the bridge can enforce, so it
93/// is withheld from the signed bridge manifest and startup validation reports
94/// it as an error. `allow` skips the client's per-call prompt (the governance
95/// chain already judges every call); `prompt` or `deny` opt the server back
96/// into the client's confirmation or block it.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct Deployment {
99    #[serde(default)]
100    pub server_type: McpServerType,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub binary: Option<String>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub package: Option<String>,
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub port: Option<u16>,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub endpoint: Option<String>,
109    pub enabled: bool,
110    pub display_in_web: bool,
111    #[serde(default)]
112    pub dev_only: bool,
113    #[serde(default)]
114    pub schemas: Vec<SchemaDefinition>,
115    pub oauth: OAuthRequirement,
116    #[serde(default)]
117    pub tools: HashMap<String, ToolMetadata>,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub model_config: Option<ToolModelConfig>,
120    #[serde(default)]
121    pub env_vars: Vec<String>,
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub external_auth: Option<ExternalAuth>,
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub connector: Option<ConnectorConfig>,
126    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
127    pub headers: HashMap<String, String>,
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub tool_policy: Option<crate::bridge::ids::ToolPolicy>,
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub description: Option<String>,
132    #[serde(default = "default_removable", skip_serializing_if = "is_removable")]
133    pub removable: bool,
134    #[serde(default, skip_serializing_if = "Vec::is_empty")]
135    pub toolsets: Vec<ToolsetRef>,
136}
137
138const fn default_removable() -> bool {
139    true
140}
141
142const fn is_removable(removable: &bool) -> bool {
143    *removable
144}
145
146impl Deployment {
147    pub fn validate(&self, name: &str) -> Result<(), ServicesValidationError> {
148        match self.server_type {
149            McpServerType::Internal => self.validate_internal(name)?,
150            McpServerType::External => self.validate_external(name)?,
151        }
152        if let Some(connector) = self.connector.as_ref() {
153            self.validate_connector(name, connector)?;
154        }
155        if let Some(ext) = self.external_auth.as_ref() {
156            validate_external_auth(name, ext)?;
157        }
158        self.validate_toolsets(name)
159    }
160
161    fn validate_internal(&self, name: &str) -> Result<(), ServicesValidationError> {
162        if self.binary.as_deref().is_none_or(|b| b.trim().is_empty()) || self.port.is_none() {
163            return Err(ServicesValidationError::invalid_field(format!(
164                "MCP server '{name}': internal servers require a binary and a port."
165            )));
166        }
167        if let Some(ep) = self.endpoint.as_deref()
168            && (ep.starts_with("http://") || ep.starts_with("https://"))
169        {
170            return Err(ServicesValidationError::invalid_field(format!(
171                "MCP server '{name}': endpoint must be a relative path (e.g. \
172                     /api/v1/mcp/{name}/mcp) or omitted; the host is derived from \
173                     server.api_external_url. Remove the scheme+host prefix."
174            )));
175        }
176        if self.external_auth.is_some() || self.connector.is_some() || !self.headers.is_empty() {
177            return Err(ServicesValidationError::invalid_field(format!(
178                "MCP server '{name}': external_auth and headers are only valid on \
179                     external servers; internal servers are reached through the gateway \
180                     with the systemprompt credential."
181            )));
182        }
183        Ok(())
184    }
185
186    fn validate_external(&self, name: &str) -> Result<(), ServicesValidationError> {
187        if self
188            .endpoint
189            .as_deref()
190            .is_none_or(|ep| ep.trim().is_empty())
191        {
192            return Err(ServicesValidationError::invalid_field(format!(
193                "MCP server '{name}': external servers require an endpoint."
194            )));
195        }
196        if self.binary.is_some() || self.package.is_some() || self.port.is_some() {
197            return Err(ServicesValidationError::invalid_field(format!(
198                "MCP server '{name}': binary, package and port are only valid on internal \
199                     servers; an external server is reached at its endpoint and is never \
200                     bound locally. Remove them."
201            )));
202        }
203        Ok(())
204    }
205
206    fn validate_connector(
207        &self,
208        name: &str,
209        connector: &ConnectorConfig,
210    ) -> Result<(), ServicesValidationError> {
211        if connector.adapter != "generic"
212            || !self
213                .endpoint
214                .as_deref()
215                .is_some_and(|endpoint| endpoint.starts_with("https://"))
216        {
217            return Err(ServicesValidationError::invalid_field(format!(
218                "MCP server '{name}': generic connector requires an HTTPS resource"
219            )));
220        }
221        if connector.client_secret.is_some() && connector.client_id_secret.is_none() {
222            return Err(ServicesValidationError::invalid_field(format!(
223                "MCP server '{name}': connector client secret requires a client ID"
224            )));
225        }
226        connector.validate(name)
227    }
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct SchemaDefinition {
232    pub file: String,
233    pub table: String,
234    pub required_columns: Vec<String>,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct OAuthRequirement {
239    pub required: bool,
240    pub scopes: Vec<Permission>,
241    pub audience: JwtAudience,
242    pub client_id: Option<ClientId>,
243    #[serde(default)]
244    pub ema: bool,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct Settings {
249    pub auto_build: bool,
250    pub build_timeout: u64,
251    pub health_check_timeout: u64,
252    #[serde(default = "default_base_port")]
253    pub base_port: u16,
254    #[serde(default = "default_working_dir")]
255    pub working_dir: String,
256}
257
258const fn default_base_port() -> u16 {
259    5000
260}
261
262fn default_working_dir() -> String {
263    "/app".to_owned()
264}