Skip to main content

systemprompt_cli/commands/plugins/mcp/
validate.rs

1use anyhow::{Context, Result, anyhow};
2use clap::Args;
3use std::sync::Arc;
4use std::time::Duration;
5
6use super::types::{McpBatchValidateOutput, McpServerInfo, McpValidateOutput, McpValidateSummary};
7use crate::CliConfig;
8use crate::interactive::{Prompter, resolve_required};
9use crate::shared::CommandOutput;
10use systemprompt_loader::ConfigLoader;
11use systemprompt_mcp::services::client::{McpConnectionResult, validate_connection_with_auth};
12use systemprompt_mcp::services::database::DatabaseService;
13use systemprompt_models::Deployment;
14use systemprompt_runtime::AppContext;
15
16#[derive(Debug, Args)]
17pub struct ValidateArgs {
18    #[arg(help = "MCP server name")]
19    pub server: Option<String>,
20
21    #[arg(
22        long = "service",
23        conflicts_with = "server",
24        help = "Alias for the positional MCP server name"
25    )]
26    pub service: Option<String>,
27
28    #[arg(long, help = "Validate all configured servers")]
29    pub all: bool,
30
31    #[arg(long, default_value = "10", help = "Connection timeout in seconds")]
32    pub timeout: u64,
33}
34
35pub(super) async fn execute(
36    args: ValidateArgs,
37    prompter: &dyn Prompter,
38    config: &CliConfig,
39) -> Result<CommandOutput> {
40    let services_config = ConfigLoader::load().context("Failed to load services configuration")?;
41
42    let ctx = AppContext::new()
43        .await
44        .context("Failed to initialize application context")?;
45
46    let database = DatabaseService::new(
47        Arc::clone(ctx.db_pool()),
48        Arc::clone(ctx.app_paths_arc()),
49        ctx.mcp_registry().clone(),
50    );
51
52    let server_arg = args.server.or(args.service);
53
54    let servers_to_validate: Vec<String> =
55        if args.all || (server_arg.is_none() && !config.is_interactive()) {
56            services_config.mcp_servers.keys().cloned().collect()
57        } else {
58            let service = resolve_required(server_arg, "server", config, || {
59                prompt_server_selection(prompter, &services_config)
60            })?;
61
62            if !services_config.mcp_servers.contains_key(&service) {
63                return Err(anyhow!("MCP server '{}' not found", service));
64            }
65
66            vec![service]
67        };
68
69    let mut results = Vec::new();
70
71    for service_name in &servers_to_validate {
72        let result =
73            validate_single_service(service_name, &services_config, &database, args.timeout).await;
74        results.push(result);
75    }
76
77    let valid_count = results.iter().filter(|r| r.valid).count();
78    let healthy_count = results
79        .iter()
80        .filter(|r| r.health_status == "healthy")
81        .count();
82
83    let output = McpBatchValidateOutput {
84        summary: McpValidateSummary {
85            total: results.len(),
86            valid: valid_count,
87            invalid: results.len() - valid_count,
88            healthy: healthy_count,
89            unhealthy: results.len() - healthy_count,
90        },
91        results,
92    };
93
94    let title = if args.all {
95        "MCP Batch Validation Results".to_owned()
96    } else {
97        format!(
98            "MCP Validation: {}",
99            servers_to_validate
100                .first()
101                .map_or("unknown", String::as_str)
102        )
103    };
104
105    Ok(CommandOutput::card_value(title, &output))
106}
107
108async fn validate_single_service(
109    service_name: &str,
110    services_config: &systemprompt_models::ServicesConfig,
111    database: &DatabaseService,
112    timeout_secs: u64,
113) -> McpValidateOutput {
114    let Some(server) = services_config.mcp_servers.get(service_name) else {
115        return failure_output(
116            service_name,
117            FailureDetail {
118                health_status: "not_found",
119                validation_type: "config_error",
120                latency_ms: 0,
121                issue: format!("Server '{}' not found in configuration", service_name),
122                message: format!("MCP server '{}' not found", service_name),
123            },
124        );
125    };
126
127    let service_info = match database.get_service_by_name(service_name).await {
128        Ok(info) => info,
129        Err(e) => {
130            return failure_output(
131                service_name,
132                FailureDetail {
133                    health_status: "unknown",
134                    validation_type: "database_error",
135                    latency_ms: 0,
136                    issue: format!("Failed to check service status: {}", e),
137                    message: format!("Database error for '{}'", service_name),
138                },
139            );
140        },
141    };
142
143    let is_running = service_info
144        .as_ref()
145        .is_some_and(|info| info.status == "running");
146
147    if !is_running {
148        return failure_output(
149            service_name,
150            FailureDetail {
151                health_status: "stopped",
152                validation_type: "not_running",
153                latency_ms: 0,
154                issue: "Service is not currently running".to_owned(),
155                message: format!("MCP server '{}' is not running", service_name),
156            },
157        );
158    }
159
160    run_connection_validation(service_name, server, timeout_secs).await
161}
162
163async fn run_connection_validation(
164    service_name: &str,
165    server: &Deployment,
166    timeout_secs: u64,
167) -> McpValidateOutput {
168    let validation_future = validate_connection_with_auth(
169        service_name,
170        "127.0.0.1",
171        server.port,
172        server.oauth.required,
173    );
174
175    let validation_result =
176        match tokio::time::timeout(Duration::from_secs(timeout_secs), validation_future).await {
177            Ok(Ok(result)) => result,
178            Ok(Err(e)) => {
179                return failure_output(
180                    service_name,
181                    FailureDetail {
182                        health_status: "unhealthy",
183                        validation_type: "connection_error",
184                        latency_ms: 0,
185                        issue: format!("Connection error: {}", e),
186                        message: format!("Failed to connect to '{}'", service_name),
187                    },
188                );
189            },
190            Err(e) => {
191                tracing::debug!(server = %service_name, error = %e, "MCP validation timed out");
192                return failure_output(
193                    service_name,
194                    FailureDetail {
195                        health_status: "unhealthy",
196                        validation_type: "timeout",
197                        latency_ms: timeout_secs as u32 * 1000,
198                        issue: format!("Connection timed out after {} seconds", timeout_secs),
199                        message: format!("Timeout connecting to '{}'", service_name),
200                    },
201                );
202            },
203        };
204
205    success_output(service_name, validation_result)
206}
207
208struct FailureDetail {
209    health_status: &'static str,
210    validation_type: &'static str,
211    latency_ms: u32,
212    issue: String,
213    message: String,
214}
215
216fn failure_output(service_name: &str, detail: FailureDetail) -> McpValidateOutput {
217    McpValidateOutput {
218        server: service_name.to_owned(),
219        valid: false,
220        health_status: detail.health_status.to_owned(),
221        validation_type: detail.validation_type.to_owned(),
222        tools_count: 0,
223        latency_ms: detail.latency_ms,
224        server_info: None,
225        issues: vec![detail.issue],
226        message: detail.message,
227    }
228}
229
230fn success_output(service_name: &str, validation_result: McpConnectionResult) -> McpValidateOutput {
231    let health_status = validation_result.health_status().to_owned();
232    let message = validation_result.status_description();
233
234    let server_info = validation_result.server_info.map(|info| McpServerInfo {
235        name: info.server_name,
236        version: info.version,
237        protocol_version: info.protocol_version,
238    });
239
240    let issues = validation_result
241        .error_message
242        .as_ref()
243        .filter(|e| !e.is_empty())
244        .map_or_else(Vec::new, |e| vec![e.clone()]);
245
246    McpValidateOutput {
247        server: service_name.to_owned(),
248        valid: validation_result.success,
249        health_status,
250        validation_type: validation_result.validation_type,
251        tools_count: validation_result.tools_count,
252        latency_ms: validation_result.connection_time_ms,
253        server_info,
254        issues,
255        message,
256    }
257}
258
259pub fn prompt_server_selection(
260    prompter: &dyn Prompter,
261    config: &systemprompt_models::ServicesConfig,
262) -> Result<String> {
263    let mut servers: Vec<String> = config.mcp_servers.keys().cloned().collect();
264    servers.sort();
265
266    if servers.is_empty() {
267        return Err(anyhow!("No MCP servers configured"));
268    }
269
270    let selection = prompter.select("Select MCP server to validate", &servers)?;
271    Ok(servers[selection].clone())
272}