systemprompt-runtime 0.59.0

Application runtime for systemprompt.io AI governance infrastructure. AppContext, lifecycle builder, extension registry, and module wiring for the MCP governance pipeline.
Documentation
//! MCP deployment manifest validation folded into the startup report.
//!
//! Copyright (c) systemprompt.io — Business Source License 1.1.
//! See <https://systemprompt.io> for licensing details.

use std::path::Path;
use systemprompt_loader::ExtensionRegistry as McpExtensionRegistry;
use systemprompt_models::mcp::McpServerType;
use systemprompt_models::{Config, ServicesConfig};
use systemprompt_traits::validation_report::ValidationIssue;
use systemprompt_traits::{StartupValidationReport, ValidationReport};

pub(super) fn validate_mcp_manifests(
    config: &Config,
    services_config: &ServicesConfig,
    report: &mut StartupValidationReport,
) {
    let registry = McpExtensionRegistry::build(
        Path::new(&config.system_path),
        config.is_cloud,
        &config.bin_path,
    );

    let mcp_errors = collect_manifest_errors(services_config, config.is_cloud, |binary| {
        registry
            .get_path(binary)
            .map(|_| ())
            .map_err(|e| e.to_string())
    });

    merge_mcp_errors(report, mcp_errors);
}

pub fn collect_manifest_errors<F>(
    services_config: &ServicesConfig,
    is_cloud: bool,
    resolve: F,
) -> Vec<ValidationIssue>
where
    F: Fn(&str) -> Result<(), String>,
{
    let mut mcp_errors: Vec<ValidationIssue> = Vec::new();

    for (name, deployment) in &services_config.mcp_servers {
        if !deployment.enabled {
            continue;
        }
        if deployment.dev_only && is_cloud {
            continue;
        }
        if deployment.tool_policy.is_none() {
            mcp_errors.push(
                ValidationIssue::new(
                    format!("mcp_servers.{}.tool_policy", name),
                    "No tool_policy declared; the server is withheld from the bridge manifest"
                        .to_owned(),
                )
                .with_suggestion(format!(
                    "Set tool_policy: allow | deny | prompt in services/mcp/{}.yaml",
                    name
                )),
            );
        }
        if !matches!(deployment.server_type, McpServerType::Internal) {
            continue;
        }

        let Some(binary) = deployment.binary.as_deref() else {
            mcp_errors.push(
                ValidationIssue::new(
                    format!("mcp_servers.{}.binary", name),
                    "Internal server declares no binary".to_owned(),
                )
                .with_suggestion(format!(
                    "Set binary: <extension binary> in services/mcp/{}.yaml",
                    name
                )),
            );
            continue;
        };

        if let Err(e) = resolve(binary) {
            mcp_errors.push(
                ValidationIssue::new(
                    format!("mcp_servers.{}.binary", name),
                    format!("Manifest not found for binary '{binary}': {e}"),
                )
                .with_suggestion(format!(
                    "Ensure manifest.yaml exists at extensions/mcp/{binary}/manifest.yaml"
                )),
            );
        }
    }

    mcp_errors
}

pub fn merge_mcp_errors(report: &mut StartupValidationReport, mcp_errors: Vec<ValidationIssue>) {
    if mcp_errors.is_empty() {
        return;
    }

    if let Some(mcp_report) = report.domains.iter_mut().find(|d| d.domain == "mcp") {
        for error in mcp_errors {
            mcp_report.add_error(error);
        }
    } else {
        let mut mcp_report = ValidationReport::new("mcp");
        for error in mcp_errors {
            mcp_report.add_error(error);
        }
        report.add_domain(mcp_report);
    }
}