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#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct Deployment {
72    #[serde(default, alias = "type")]
73    pub server_type: McpServerType,
74    pub binary: String,
75    pub package: Option<String>,
76    pub port: u16,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub endpoint: Option<String>,
79    pub enabled: bool,
80    pub display_in_web: bool,
81    #[serde(default)]
82    pub dev_only: bool,
83    #[serde(default)]
84    pub schemas: Vec<SchemaDefinition>,
85    pub oauth: OAuthRequirement,
86    #[serde(default)]
87    pub tools: HashMap<String, ToolMetadata>,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub model_config: Option<ToolModelConfig>,
90    #[serde(default)]
91    pub env_vars: Vec<String>,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub external_auth: Option<ExternalAuth>,
94    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
95    pub headers: HashMap<String, String>,
96}
97
98/// Per-user bearer resolution for an `external` MCP server.
99///
100/// The MCP gateway exposes no token vault of its own; instead an extension
101/// banks the calling user's third-party token and serves it from
102/// `token_endpoint`. At tool-call time core `GET`s that accessor with the
103/// user's systemprompt JWT and injects the returned bearer onto `header` (as
104/// `{scheme} {token}`), replacing the systemprompt credential so nothing
105/// internal reaches the third party.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct ExternalAuth {
108    pub token_endpoint: String,
109    #[serde(default = "default_auth_header")]
110    pub header: String,
111    #[serde(default = "default_auth_scheme")]
112    pub scheme: String,
113}
114
115fn default_auth_header() -> String {
116    "Authorization".to_owned()
117}
118
119fn default_auth_scheme() -> String {
120    "Bearer".to_owned()
121}
122
123impl ExternalAuth {
124    /// The value to send on [`Self::header`] for `bearer`: `"{scheme}
125    /// {token}"`, or the raw token when `scheme` is empty (providers that
126    /// expect a bare credential, e.g. an `X-Api-Key`).
127    pub fn header_value(&self, bearer: &str) -> String {
128        if self.scheme.trim().is_empty() {
129            bearer.to_owned()
130        } else {
131            format!("{} {bearer}", self.scheme)
132        }
133    }
134}
135
136impl Deployment {
137    pub fn validate(&self, name: &str) -> Result<(), ConfigValidationError> {
138        if matches!(self.server_type, McpServerType::Internal) {
139            if let Some(ep) = self.endpoint.as_deref()
140                && (ep.starts_with("http://") || ep.starts_with("https://"))
141            {
142                return Err(ConfigValidationError::invalid_field(format!(
143                    "MCP server '{name}': endpoint must be a relative path (e.g. \
144                         /api/v1/mcp/{name}/mcp) or omitted; the host is derived from \
145                         server.api_external_url. Remove the scheme+host prefix."
146                )));
147            }
148            if self.external_auth.is_some() || !self.headers.is_empty() {
149                return Err(ConfigValidationError::invalid_field(format!(
150                    "MCP server '{name}': external_auth and headers are only valid on \
151                         external servers; internal servers are reached through the gateway \
152                         with the systemprompt credential."
153                )));
154            }
155        }
156
157        if let Some(ext) = self.external_auth.as_ref() {
158            if ext.token_endpoint.starts_with("http://")
159                || ext.token_endpoint.starts_with("https://")
160            {
161                return Err(ConfigValidationError::invalid_field(format!(
162                    "MCP server '{name}': external_auth.token_endpoint must be a relative \
163                         path (e.g. /api/public/<provider>/token); the host is derived from \
164                         server.api_external_url. Remove the scheme+host prefix."
165                )));
166            }
167            if !ext.token_endpoint.starts_with('/') {
168                return Err(ConfigValidationError::invalid_field(format!(
169                    "MCP server '{name}': external_auth.token_endpoint must be an absolute \
170                         path beginning with '/'."
171                )));
172            }
173            if ext.header.trim().is_empty() {
174                return Err(ConfigValidationError::invalid_field(format!(
175                    "MCP server '{name}': external_auth.header must not be empty."
176                )));
177            }
178        }
179
180        Ok(())
181    }
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct SchemaDefinition {
186    pub file: String,
187    pub table: String,
188    pub required_columns: Vec<String>,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct OAuthRequirement {
193    pub required: bool,
194    pub scopes: Vec<Permission>,
195    pub audience: JwtAudience,
196    pub client_id: Option<ClientId>,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct Settings {
201    pub auto_build: bool,
202    pub build_timeout: u64,
203    pub health_check_timeout: u64,
204    #[serde(default = "default_base_port")]
205    pub base_port: u16,
206    #[serde(default = "default_working_dir")]
207    pub working_dir: String,
208}
209
210const fn default_base_port() -> u16 {
211    5000
212}
213
214fn default_working_dir() -> String {
215    "/app".to_owned()
216}