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