systemprompt-cli 0.2.1

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::CommandResult;
use systemprompt_loader::ConfigLoader;
use systemprompt_mcp::services::McpManager;
use systemprompt_mcp::services::client::validate_connection_with_auth;
use systemprompt_mcp::services::database::DatabaseManager;
use systemprompt_runtime::AppContext;

#[derive(Debug, Args)]
pub struct ValidateArgs {
    #[arg(help = "MCP server name")]
    pub server: 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 async fn execute(
    args: ValidateArgs,
    config: &CliConfig,
) -> Result<CommandResult<McpBatchValidateOutput>> {
    let services_config = ConfigLoader::load().context("Failed to load services configuration")?;

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

    let _manager =
        McpManager::new(Arc::clone(ctx.db_pool())).context("Failed to initialize MCP manager")?;
    let database = DatabaseManager::new(Arc::clone(ctx.db_pool()));

    let servers_to_validate: Vec<String> =
        if args.all || (args.server.is_none() && !config.is_interactive()) {
            services_config.mcp_servers.keys().cloned().collect()
        } else {
            let service = resolve_required(args.server, "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_string()
    } else {
        format!(
            "MCP Validation: {}",
            servers_to_validate
                .first()
                .map_or("unknown", String::as_str)
        )
    };

    Ok(CommandResult::card(output).with_title(title))
}

async fn validate_single_service(
    service_name: &str,
    services_config: &systemprompt_models::ServicesConfig,
    database: &DatabaseManager,
    timeout_secs: u64,
) -> McpValidateOutput {
    let Some(server) = services_config.mcp_servers.get(service_name) else {
        return McpValidateOutput {
            server: service_name.to_string(),
            valid: false,
            health_status: "not_found".to_string(),
            validation_type: "config_error".to_string(),
            tools_count: 0,
            latency_ms: 0,
            server_info: None,
            issues: vec![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 McpValidateOutput {
                server: service_name.to_string(),
                valid: false,
                health_status: "unknown".to_string(),
                validation_type: "database_error".to_string(),
                tools_count: 0,
                latency_ms: 0,
                server_info: None,
                issues: vec![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 McpValidateOutput {
            server: service_name.to_string(),
            valid: false,
            health_status: "stopped".to_string(),
            validation_type: "not_running".to_string(),
            tools_count: 0,
            latency_ms: 0,
            server_info: None,
            issues: vec!["Service is not currently running".to_string()],
            message: format!("MCP server '{}' is not running", service_name),
        };
    }

    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 McpValidateOutput {
                    server: service_name.to_string(),
                    valid: false,
                    health_status: "unhealthy".to_string(),
                    validation_type: "connection_error".to_string(),
                    tools_count: 0,
                    latency_ms: 0,
                    server_info: None,
                    issues: vec![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 McpValidateOutput {
                    server: service_name.to_string(),
                    valid: false,
                    health_status: "unhealthy".to_string(),
                    validation_type: "timeout".to_string(),
                    tools_count: 0,
                    latency_ms: timeout_secs as u32 * 1000,
                    server_info: None,
                    issues: vec![format!(
                        "Connection timed out after {} seconds",
                        timeout_secs
                    )],
                    message: format!("Timeout connecting to '{}'", service_name),
                };
            },
        };

    let health_status = validation_result.health_status().to_string();
    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_string(),
        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())
}