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