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