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 (**app.service_repository()).clone(),
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 let Some(port) = server.port else {
164 return failure_output(
165 service_name,
166 FailureDetail {
167 health_status: "unknown",
168 validation_type: "config_error",
169 latency_ms: 0,
170 issue: "Server declares no local port; external servers are validated at their endpoint".to_owned(),
171 message: format!("MCP server '{}' has no local port", service_name),
172 },
173 );
174 };
175
176 run_connection_validation(service_name, server, port, timeout_secs).await
177}
178
179pub async fn run_connection_validation(
180 service_name: &str,
181 server: &Deployment,
182 port: u16,
183 timeout_secs: u64,
184) -> McpValidateOutput {
185 let validation_future =
186 validate_connection_with_auth(service_name, "127.0.0.1", port, server.oauth.required);
187
188 let validation_result =
189 match tokio::time::timeout(Duration::from_secs(timeout_secs), validation_future).await {
190 Ok(Ok(result)) => result,
191 Ok(Err(e)) => {
192 return failure_output(
193 service_name,
194 FailureDetail {
195 health_status: "unhealthy",
196 validation_type: "connection_error",
197 latency_ms: 0,
198 issue: format!("Connection error: {}", e),
199 message: format!("Failed to connect to '{}'", service_name),
200 },
201 );
202 },
203 Err(e) => {
204 tracing::debug!(server = %service_name, error = %e, "MCP validation timed out");
205 return failure_output(
206 service_name,
207 FailureDetail {
208 health_status: "unhealthy",
209 validation_type: "timeout",
210 latency_ms: timeout_secs as u32 * 1000,
211 issue: format!("Connection timed out after {} seconds", timeout_secs),
212 message: format!("Timeout connecting to '{}'", service_name),
213 },
214 );
215 },
216 };
217
218 success_output(service_name, validation_result)
219}
220
221#[derive(Debug)]
222pub struct FailureDetail {
223 pub health_status: &'static str,
224 pub validation_type: &'static str,
225 pub latency_ms: u32,
226 pub issue: String,
227 pub message: String,
228}
229
230pub fn failure_output(service_name: &str, detail: FailureDetail) -> McpValidateOutput {
231 McpValidateOutput {
232 server: service_name.to_owned(),
233 valid: false,
234 health_status: detail.health_status.to_owned(),
235 validation_type: detail.validation_type.to_owned(),
236 tools_count: None,
237 latency_ms: detail.latency_ms,
238 server_info: None,
239 issues: vec![detail.issue],
240 message: detail.message,
241 }
242}
243
244pub fn success_output(
245 service_name: &str,
246 validation_result: McpConnectionResult,
247) -> McpValidateOutput {
248 let health_status = validation_result.health_status().to_owned();
249 let message = validation_result.status_description();
250
251 let server_info = validation_result.server_info.map(|info| McpServerInfo {
252 name: info.server_name,
253 version: info.version,
254 protocol_version: info.protocol_version,
255 });
256
257 let issues = validation_result
258 .error_message
259 .as_ref()
260 .filter(|e| !e.is_empty())
261 .map_or_else(Vec::new, |e| vec![e.clone()]);
262
263 McpValidateOutput {
264 server: service_name.to_owned(),
265 valid: validation_result.success,
266 health_status,
267 validation_type: validation_result.validation_type,
268 tools_count: validation_result.tools_count,
269 latency_ms: validation_result.connection_time_ms,
270 server_info,
271 issues,
272 message,
273 }
274}
275
276pub fn prompt_server_selection(
277 prompter: &dyn Prompter,
278 config: &systemprompt_models::ServicesConfig,
279) -> Result<String> {
280 let mut servers: Vec<String> = config.mcp_servers.keys().cloned().collect();
281 servers.sort();
282
283 if servers.is_empty() {
284 return Err(anyhow!("No MCP servers configured"));
285 }
286
287 let selection = prompter.select("Select MCP server to validate", &servers)?;
288 Ok(servers[selection].clone())
289}