systemprompt-cli 0.16.0

Unified CLI for systemprompt.io AI governance: agent orchestration, MCP governance, analytics, profiles, cloud deploy, and self-hosted operations.
Documentation
use anyhow::{Context, Result, anyhow};
use clap::Args;
use dialoguer::Select;
use dialoguer::theme::ColorfulTheme;
use std::sync::Arc;
use std::time::Duration;

use super::types::{McpBatchValidateOutput, McpServerInfo, McpValidateOutput, McpValidateSummary};
use crate::CliConfig;
use crate::interactive::resolve_required;
use crate::shared::CommandOutput;
use systemprompt_loader::ConfigLoader;
use systemprompt_mcp::services::client::{McpConnectionResult, validate_connection_with_auth};
use systemprompt_mcp::services::database::DatabaseService;
use systemprompt_models::Deployment;
use systemprompt_runtime::AppContext;

#[derive(Debug, Args)]
pub struct ValidateArgs {
    #[arg(help = "MCP server name")]
    pub server: Option<String>,

    #[arg(
        long = "service",
        conflicts_with = "server",
        help = "Alias for the positional MCP server name"
    )]
    pub service: Option<String>,

    #[arg(long, help = "Validate all configured servers")]
    pub all: bool,

    #[arg(long, default_value = "10", help = "Connection timeout in seconds")]
    pub timeout: u64,
}

pub(super) async fn execute(args: ValidateArgs, config: &CliConfig) -> Result<CommandOutput> {
    let services_config = ConfigLoader::load().context("Failed to load services configuration")?;

    let ctx = AppContext::new()
        .await
        .context("Failed to initialize application context")?;

    let database = DatabaseService::new(
        Arc::clone(ctx.db_pool()),
        Arc::clone(ctx.app_paths_arc()),
        ctx.mcp_registry().clone(),
    );

    let server_arg = args.server.or(args.service);

    let servers_to_validate: Vec<String> =
        if args.all || (server_arg.is_none() && !config.is_interactive()) {
            services_config.mcp_servers.keys().cloned().collect()
        } else {
            let service = resolve_required(server_arg, "server", config, || {
                prompt_server_selection(&services_config)
            })?;

            if !services_config.mcp_servers.contains_key(&service) {
                return Err(anyhow!("MCP server '{}' not found", service));
            }

            vec![service]
        };

    let mut results = Vec::new();

    for service_name in &servers_to_validate {
        let result =
            validate_single_service(service_name, &services_config, &database, args.timeout).await;
        results.push(result);
    }

    let valid_count = results.iter().filter(|r| r.valid).count();
    let healthy_count = results
        .iter()
        .filter(|r| r.health_status == "healthy")
        .count();

    let output = McpBatchValidateOutput {
        summary: McpValidateSummary {
            total: results.len(),
            valid: valid_count,
            invalid: results.len() - valid_count,
            healthy: healthy_count,
            unhealthy: results.len() - healthy_count,
        },
        results,
    };

    let title = if args.all {
        "MCP Batch Validation Results".to_owned()
    } else {
        format!(
            "MCP Validation: {}",
            servers_to_validate
                .first()
                .map_or("unknown", String::as_str)
        )
    };

    Ok(CommandOutput::card_value(title, &output))
}

async fn validate_single_service(
    service_name: &str,
    services_config: &systemprompt_models::ServicesConfig,
    database: &DatabaseService,
    timeout_secs: u64,
) -> McpValidateOutput {
    let Some(server) = services_config.mcp_servers.get(service_name) else {
        return failure_output(
            service_name,
            FailureDetail {
                health_status: "not_found",
                validation_type: "config_error",
                latency_ms: 0,
                issue: format!("Server '{}' not found in configuration", service_name),
                message: format!("MCP server '{}' not found", service_name),
            },
        );
    };

    let service_info = match database.get_service_by_name(service_name).await {
        Ok(info) => info,
        Err(e) => {
            return failure_output(
                service_name,
                FailureDetail {
                    health_status: "unknown",
                    validation_type: "database_error",
                    latency_ms: 0,
                    issue: format!("Failed to check service status: {}", e),
                    message: format!("Database error for '{}'", service_name),
                },
            );
        },
    };

    let is_running = service_info
        .as_ref()
        .is_some_and(|info| info.status == "running");

    if !is_running {
        return failure_output(
            service_name,
            FailureDetail {
                health_status: "stopped",
                validation_type: "not_running",
                latency_ms: 0,
                issue: "Service is not currently running".to_owned(),
                message: format!("MCP server '{}' is not running", service_name),
            },
        );
    }

    run_connection_validation(service_name, server, timeout_secs).await
}

async fn run_connection_validation(
    service_name: &str,
    server: &Deployment,
    timeout_secs: u64,
) -> McpValidateOutput {
    let validation_future = validate_connection_with_auth(
        service_name,
        "127.0.0.1",
        server.port,
        server.oauth.required,
    );

    let validation_result =
        match tokio::time::timeout(Duration::from_secs(timeout_secs), validation_future).await {
            Ok(Ok(result)) => result,
            Ok(Err(e)) => {
                return failure_output(
                    service_name,
                    FailureDetail {
                        health_status: "unhealthy",
                        validation_type: "connection_error",
                        latency_ms: 0,
                        issue: format!("Connection error: {}", e),
                        message: format!("Failed to connect to '{}'", service_name),
                    },
                );
            },
            Err(e) => {
                tracing::debug!(server = %service_name, error = %e, "MCP validation timed out");
                return failure_output(
                    service_name,
                    FailureDetail {
                        health_status: "unhealthy",
                        validation_type: "timeout",
                        latency_ms: timeout_secs as u32 * 1000,
                        issue: format!("Connection timed out after {} seconds", timeout_secs),
                        message: format!("Timeout connecting to '{}'", service_name),
                    },
                );
            },
        };

    success_output(service_name, validation_result)
}

struct FailureDetail {
    health_status: &'static str,
    validation_type: &'static str,
    latency_ms: u32,
    issue: String,
    message: String,
}

fn failure_output(service_name: &str, detail: FailureDetail) -> McpValidateOutput {
    McpValidateOutput {
        server: service_name.to_owned(),
        valid: false,
        health_status: detail.health_status.to_owned(),
        validation_type: detail.validation_type.to_owned(),
        tools_count: 0,
        latency_ms: detail.latency_ms,
        server_info: None,
        issues: vec![detail.issue],
        message: detail.message,
    }
}

fn success_output(service_name: &str, validation_result: McpConnectionResult) -> McpValidateOutput {
    let health_status = validation_result.health_status().to_owned();
    let message = validation_result.status_description();

    let server_info = validation_result.server_info.map(|info| McpServerInfo {
        name: info.server_name,
        version: info.version,
        protocol_version: info.protocol_version,
    });

    let issues = validation_result
        .error_message
        .as_ref()
        .filter(|e| !e.is_empty())
        .map_or_else(Vec::new, |e| vec![e.clone()]);

    McpValidateOutput {
        server: service_name.to_owned(),
        valid: validation_result.success,
        health_status,
        validation_type: validation_result.validation_type,
        tools_count: validation_result.tools_count,
        latency_ms: validation_result.connection_time_ms,
        server_info,
        issues,
        message,
    }
}

fn prompt_server_selection(config: &systemprompt_models::ServicesConfig) -> Result<String> {
    let mut servers: Vec<&String> = config.mcp_servers.keys().collect();
    servers.sort();

    if servers.is_empty() {
        return Err(anyhow!("No MCP servers configured"));
    }

    let selection = Select::with_theme(&ColorfulTheme::default())
        .with_prompt("Select MCP server to validate")
        .items(&servers)
        .default(0)
        .interact()
        .context("Failed to get server selection")?;

    Ok(servers[selection].clone())
}