Skip to main content

systemprompt_runtime/startup_validation/
mcp_validator.rs

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