Skip to main content

systemprompt_runtime/startup_validation/
mcp_validator.rs

1//! MCP deployment manifest validation folded into the startup report.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use std::path::Path;
7use systemprompt_loader::ExtensionRegistry as McpExtensionRegistry;
8use systemprompt_models::mcp::McpServerType;
9use systemprompt_models::{Config, ServicesConfig};
10use systemprompt_traits::validation_report::ValidationError;
11use systemprompt_traits::{StartupValidationReport, ValidationReport};
12
13pub(super) fn validate_mcp_manifests(
14    config: &Config,
15    services_config: &ServicesConfig,
16    report: &mut StartupValidationReport,
17) {
18    let registry = McpExtensionRegistry::build(
19        Path::new(&config.system_path),
20        config.is_cloud,
21        &config.bin_path,
22    );
23
24    let mcp_errors = collect_manifest_errors(services_config, config.is_cloud, |binary| {
25        registry
26            .get_path(binary)
27            .map(|_| ())
28            .map_err(|e| e.to_string())
29    });
30
31    merge_mcp_errors(report, mcp_errors);
32}
33
34/// Resolve every enabled Internal MCP deployment against `resolve` and collect
35/// a [`ValidationError`] for each binary whose manifest cannot be located.
36///
37/// External servers (reached at their endpoint) and, on cloud, `dev_only`
38/// servers are skipped. `resolve` maps a deployment binary to `Ok(())` when its
39/// manifest exists or `Err(reason)` otherwise; the serve boot path binds it to
40/// the extension registry, tests bind a stub so the branch matrix is drivable
41/// without a manifest tree.
42pub fn collect_manifest_errors<F>(
43    services_config: &ServicesConfig,
44    is_cloud: bool,
45    resolve: F,
46) -> Vec<ValidationError>
47where
48    F: Fn(&str) -> Result<(), String>,
49{
50    let mut mcp_errors: Vec<ValidationError> = Vec::new();
51
52    for (name, deployment) in &services_config.mcp_servers {
53        if !deployment.enabled {
54            continue;
55        }
56        if deployment.dev_only && is_cloud {
57            continue;
58        }
59        // External servers carry an empty `binary` and are reached at their
60        // endpoint; only Internal servers resolve to an extension manifest.
61        if !matches!(deployment.server_type, McpServerType::Internal) {
62            continue;
63        }
64
65        if let Err(e) = resolve(&deployment.binary) {
66            mcp_errors.push(
67                ValidationError::new(
68                    format!("mcp_servers.{}.binary", name),
69                    format!(
70                        "Manifest not found for binary '{}': {}",
71                        deployment.binary, e
72                    ),
73                )
74                .with_suggestion(format!(
75                    "Ensure manifest.yaml exists at extensions/mcp/{}/manifest.yaml",
76                    deployment.binary
77                )),
78            );
79        }
80    }
81
82    mcp_errors
83}
84
85/// Fold collected MCP manifest errors into `report`.
86///
87/// Appends to the existing `mcp` domain report when the domain-validation pass
88/// already produced one and creates a fresh `mcp` domain otherwise. A no-op
89/// when `mcp_errors` is empty.
90pub fn merge_mcp_errors(report: &mut StartupValidationReport, mcp_errors: Vec<ValidationError>) {
91    if mcp_errors.is_empty() {
92        return;
93    }
94
95    if let Some(mcp_report) = report.domains.iter_mut().find(|d| d.domain == "mcp") {
96        for error in mcp_errors {
97            mcp_report.add_error(error);
98        }
99    } else {
100        let mut mcp_report = ValidationReport::new("mcp");
101        for error in mcp_errors {
102            mcp_report.add_error(error);
103        }
104        report.add_domain(mcp_report);
105    }
106}