Skip to main content

systemprompt_cli/commands/plugins/mcp/
validate.rs

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