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, 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}