systemprompt_cli/commands/plugins/mcp/
validate.rs1use 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}