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
9use crate::ai::ToolModelConfig;
10use crate::auth::{JwtAudience, Permission};
11use crate::errors::ConfigValidationError;
12use crate::mcp::capabilities::ToolVisibility;
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use systemprompt_identifiers::ClientId;
16
17#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
18pub enum McpServerType {
19    #[default]
20    #[serde(rename = "internal")]
21    Internal,
22    #[serde(rename = "external")]
23    External,
24}
25
26impl McpServerType {
27    pub const fn as_str(&self) -> &'static str {
28        match self {
29            Self::Internal => "internal",
30            Self::External => "external",
31        }
32    }
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, Default)]
36pub struct ToolUiConfig {
37    #[serde(default = "default_resource_uri_template")]
38    pub resource_uri_template: String,
39    #[serde(default = "default_visibility_enum")]
40    pub visibility: Vec<ToolVisibility>,
41}
42
43fn default_resource_uri_template() -> String {
44    "ui://systemprompt/{artifact_id}".to_owned()
45}
46
47fn default_visibility_enum() -> Vec<ToolVisibility> {
48    vec![ToolVisibility::Model, ToolVisibility::App]
49}
50
51impl ToolUiConfig {
52    pub fn new() -> Self {
53        Self::default()
54    }
55
56    pub fn with_template(mut self, template: impl Into<String>) -> Self {
57        self.resource_uri_template = template.into();
58        self
59    }
60
61    pub fn model_only(mut self) -> Self {
62        self.visibility = vec![ToolVisibility::Model];
63        self
64    }
65
66    pub fn model_and_app(mut self) -> Self {
67        self.visibility = vec![ToolVisibility::Model, ToolVisibility::App];
68        self
69    }
70
71    pub fn to_meta_json(&self) -> serde_json::Value {
72        serde_json::json!({
73            "ui": {
74                "resourceUri": self.resource_uri_template,
75                "visibility": self.visibility
76            }
77        })
78    }
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, Default)]
82pub struct ToolMetadata {
83    #[serde(default)]
84    pub terminal_on_success: bool,
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub model_config: Option<ToolModelConfig>,
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub ui: Option<ToolUiConfig>,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct DeploymentConfig {
93    pub deployments: HashMap<String, Deployment>,
94    pub settings: Settings,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct Deployment {
99    #[serde(default, alias = "type")]
100    pub server_type: McpServerType,
101    pub binary: String,
102    pub package: Option<String>,
103    pub port: u16,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub endpoint: Option<String>,
106    pub enabled: bool,
107    pub display_in_web: bool,
108    #[serde(default)]
109    pub dev_only: bool,
110    #[serde(default)]
111    pub schemas: Vec<SchemaDefinition>,
112    pub oauth: OAuthRequirement,
113    #[serde(default)]
114    pub tools: HashMap<String, ToolMetadata>,
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub model_config: Option<ToolModelConfig>,
117    #[serde(default)]
118    pub env_vars: Vec<String>,
119}
120
121impl Deployment {
122    pub fn validate(&self, name: &str) -> Result<(), ConfigValidationError> {
123        if matches!(self.server_type, McpServerType::Internal) {
124            if let Some(ep) = self.endpoint.as_deref() {
125                if ep.starts_with("http://") || ep.starts_with("https://") {
126                    return Err(ConfigValidationError::invalid_field(format!(
127                        "MCP server '{name}': endpoint must be a relative path (e.g. \
128                         /api/v1/mcp/{name}/mcp) or omitted; the host is derived from \
129                         server.api_external_url. Remove the scheme+host prefix."
130                    )));
131                }
132            }
133        }
134        Ok(())
135    }
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct SchemaDefinition {
140    pub file: String,
141    pub table: String,
142    pub required_columns: Vec<String>,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct OAuthRequirement {
147    pub required: bool,
148    pub scopes: Vec<Permission>,
149    pub audience: JwtAudience,
150    pub client_id: Option<ClientId>,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct Settings {
155    pub auto_build: bool,
156    pub build_timeout: u64,
157    pub health_check_timeout: u64,
158    #[serde(default = "default_base_port")]
159    pub base_port: u16,
160    #[serde(default = "default_working_dir")]
161    pub working_dir: String,
162}
163
164const fn default_base_port() -> u16 {
165    5000
166}
167
168fn default_working_dir() -> String {
169    "/app".to_owned()
170}