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