Skip to main content

mcp_execution_cli/commands/
introspect.rs

1//! Introspect command implementation.
2//!
3//! Connects to an MCP server and displays its capabilities, tools, and metadata.
4
5use super::common::{build_server_config, load_server_from_config};
6use anyhow::{Context, Result};
7use mcp_execution_core::cli::{ExitCode, OutputFormat};
8use mcp_execution_introspector::{Introspector, ServerInfo, ToolInfo};
9use serde::Serialize;
10use tracing::{debug, info};
11
12/// Result of server introspection.
13///
14/// Contains server information and list of available tools,
15/// formatted for display to the user.
16///
17/// # Examples
18///
19/// ```
20/// use mcp_execution_cli::commands::introspect::{IntrospectionResult, ServerMetadata};
21///
22/// let result = IntrospectionResult {
23///     server: ServerMetadata {
24///         id: "github".to_string(),
25///         name: "github".to_string(),
26///         version: "1.0.0".to_string(),
27///         supports_tools: true,
28///         supports_resources: false,
29///         supports_prompts: false,
30///     },
31///     tools: vec![],
32/// };
33///
34/// assert_eq!(result.server.name, "github");
35/// ```
36#[derive(Debug, Clone, Serialize)]
37pub struct IntrospectionResult {
38    /// Server metadata
39    pub server: ServerMetadata,
40    /// List of available tools
41    pub tools: Vec<ToolDisplay>,
42}
43
44/// Server metadata for display.
45///
46/// Simplified representation of server information optimized
47/// for CLI output formatting.
48#[derive(Debug, Clone, Serialize)]
49pub struct ServerMetadata {
50    /// Server identifier
51    pub id: String,
52    /// Server name
53    pub name: String,
54    /// Server version
55    pub version: String,
56    /// Whether server supports tools
57    pub supports_tools: bool,
58    /// Whether server supports resources
59    pub supports_resources: bool,
60    /// Whether server supports prompts
61    pub supports_prompts: bool,
62}
63
64/// Tool information formatted for CLI display.
65///
66/// Contains tool information with optional schema details
67/// when detailed output is requested.
68#[derive(Debug, Clone, Serialize)]
69pub struct ToolDisplay {
70    /// Tool name
71    pub name: String,
72    /// Tool description
73    pub description: String,
74    /// Input schema (only included when detailed mode is enabled)
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub input_schema: Option<serde_json::Value>,
77    /// Output schema (only included when detailed mode is enabled and available)
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub output_schema: Option<serde_json::Value>,
80}
81
82/// Runs the introspect command.
83///
84/// Connects to the specified server, discovers its tools, and displays
85/// information according to the output format.
86///
87/// # Process
88///
89/// 1. Builds `ServerConfig` from CLI arguments or loads from ~/.claude/mcp.json
90/// 2. Creates an introspector and connects to the server
91/// 3. Discovers server capabilities and tools
92/// 4. Formats the output according to the specified format
93/// 5. Displays the results to stdout
94///
95/// # Arguments
96///
97/// * `from_config` - Load server config from ~/.claude/mcp.json by name
98/// * `server` - Server command (binary name or path), None for HTTP/SSE
99/// * `args` - Arguments to pass to the server command
100/// * `env` - Environment variables in KEY=VALUE format
101/// * `cwd` - Working directory for the server process
102/// * `http` - HTTP transport URL
103/// * `sse` - SSE transport URL
104/// * `headers` - HTTP headers in KEY=VALUE format
105/// * `detailed` - Whether to show detailed tool schemas
106/// * `connect_timeout_secs` - Connection (handshake) timeout override, in
107///   seconds. Ignored when `from_config` is set (the `mcp.json` entry's
108///   `connectTimeoutSecs` applies instead). Same bounds as `mcp.json`: must
109///   be greater than zero and at most 600 seconds.
110/// * `discover_timeout_secs` - Tool discovery timeout override, in seconds.
111///   Same rules as `connect_timeout_secs`.
112/// * `output_format` - Output format (json, text, pretty)
113///
114/// # Errors
115///
116/// Returns an error if:
117/// - Server configuration is invalid
118/// - Server connection fails
119/// - Server introspection fails
120/// - Output formatting fails
121///
122/// # Examples
123///
124/// ```no_run
125/// use mcp_execution_cli::commands::introspect;
126/// use mcp_execution_core::cli::OutputFormat;
127///
128/// # async fn example() -> anyhow::Result<()> {
129/// // Simple server
130/// let exit_code = introspect::run(
131///     None,
132///     Some("github-mcp-server".to_string()),
133///     vec!["stdio".to_string()],
134///     vec![],
135///     None,
136///     None,
137///     None,
138///     vec![],
139///     false,
140///     None,
141///     None,
142///     OutputFormat::Json
143/// ).await?;
144///
145/// // HTTP transport with a shorter connect timeout
146/// let exit_code = introspect::run(
147///     None,
148///     None,
149///     vec![],
150///     vec![],
151///     None,
152///     Some("https://api.githubcopilot.com/mcp/".to_string()),
153///     None,
154///     vec!["Authorization=Bearer token".to_string()],
155///     false,
156///     Some(5),
157///     None,
158///     OutputFormat::Json
159/// ).await?;
160/// # Ok(())
161/// # }
162/// ```
163// One argument per CLI flag; clap already destructures flags for us, and
164// grouping them into a struct would only benefit this function, not caller ergonomics.
165#[allow(clippy::too_many_arguments)]
166pub async fn run(
167    from_config: Option<String>,
168    server: Option<String>,
169    args: Vec<String>,
170    env: Vec<String>,
171    cwd: Option<String>,
172    http: Option<String>,
173    sse: Option<String>,
174    headers: Vec<String>,
175    detailed: bool,
176    connect_timeout_secs: Option<u64>,
177    discover_timeout_secs: Option<u64>,
178    output_format: OutputFormat,
179) -> Result<ExitCode> {
180    // Build server config: either from mcp.json or from CLI arguments
181    let (server_id, config) = if let Some(config_name) = from_config {
182        debug!(
183            "Loading server configuration from ~/.claude/mcp.json: {}",
184            config_name
185        );
186        load_server_from_config(&config_name)?
187    } else {
188        build_server_config(
189            server,
190            args,
191            env,
192            cwd,
193            http,
194            sse,
195            headers,
196            connect_timeout_secs,
197            discover_timeout_secs,
198        )?
199    };
200
201    info!("Introspecting server: {}", server_id);
202    info!("Transport: {:?}", config.transport());
203    info!("Detailed: {}", detailed);
204    info!("Output format: {}", output_format);
205
206    // Create introspector
207    let mut introspector = Introspector::new();
208
209    // Discover server
210    let server_info = introspector
211        .discover_server(server_id.clone(), &config)
212        .await
213        .with_context(|| {
214            format!(
215                "failed to connect to server '{server_id}' - ensure the server is installed and accessible"
216            )
217        })?;
218
219    info!(
220        "Successfully discovered {} tools from server",
221        server_info.tools.len()
222    );
223
224    // Build result
225    let result = build_result(&server_info, detailed);
226
227    // Format and display output
228    let formatted = crate::formatters::format_output(&result, output_format)
229        .context("failed to format introspection results")?;
230
231    println!("{formatted}");
232
233    Ok(ExitCode::SUCCESS)
234}
235
236/// Builds the introspection result from server info.
237///
238/// Transforms `ServerInfo` into `IntrospectionResult` suitable for CLI display.
239///
240/// # Arguments
241///
242/// * `server_info` - Server information from introspector
243/// * `detailed` - Whether to include detailed tool schemas
244///
245/// # Examples
246///
247/// ```
248/// use mcp_execution_cli::commands::introspect::build_result;
249/// use mcp_execution_introspector::{ServerInfo, ServerCapabilities};
250/// use mcp_execution_core::ServerId;
251///
252/// let server_info = ServerInfo {
253///     id: ServerId::new("test"),
254///     name: "Test Server".to_string(),
255///     version: "1.0.0".to_string(),
256///     tools: vec![],
257///     capabilities: ServerCapabilities {
258///         supports_tools: true,
259///         supports_resources: false,
260///         supports_prompts: false,
261///     },
262/// };
263///
264/// let result = build_result(&server_info, false);
265/// assert_eq!(result.server.name, "Test Server");
266/// assert_eq!(result.tools.len(), 0);
267/// ```
268#[must_use]
269pub fn build_result(server_info: &ServerInfo, detailed: bool) -> IntrospectionResult {
270    let server = ServerMetadata {
271        id: server_info.id.as_str().to_string(),
272        name: server_info.name.clone(),
273        version: server_info.version.clone(),
274        supports_tools: server_info.capabilities.supports_tools,
275        supports_resources: server_info.capabilities.supports_resources,
276        supports_prompts: server_info.capabilities.supports_prompts,
277    };
278
279    let tools = server_info
280        .tools
281        .iter()
282        .map(|tool| build_tool_metadata(tool, detailed))
283        .collect();
284
285    IntrospectionResult { server, tools }
286}
287
288/// Builds tool metadata from tool info.
289///
290/// Transforms `ToolInfo` into `ToolDisplay` with optional schema details.
291///
292/// # Arguments
293///
294/// * `tool_info` - Tool information from introspector
295/// * `detailed` - Whether to include input/output schemas
296fn build_tool_metadata(tool_info: &ToolInfo, detailed: bool) -> ToolDisplay {
297    ToolDisplay {
298        name: tool_info.name.as_str().to_string(),
299        description: tool_info.description.clone(),
300        input_schema: if detailed {
301            Some(tool_info.input_schema.clone())
302        } else {
303            None
304        },
305        output_schema: if detailed {
306            tool_info.output_schema.clone()
307        } else {
308            None
309        },
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use mcp_execution_core::{ServerId, ToolName};
317    use mcp_execution_introspector::ServerCapabilities;
318    use serde_json::json;
319
320    #[test]
321    fn test_build_result_basic() {
322        let server_info = ServerInfo {
323            id: ServerId::new("test-server"),
324            name: "Test Server".to_string(),
325            version: "1.0.0".to_string(),
326            tools: vec![],
327            capabilities: ServerCapabilities {
328                supports_tools: true,
329                supports_resources: false,
330                supports_prompts: false,
331            },
332        };
333
334        let result = build_result(&server_info, false);
335
336        assert_eq!(result.server.id, "test-server");
337        assert_eq!(result.server.name, "Test Server");
338        assert_eq!(result.server.version, "1.0.0");
339        assert!(result.server.supports_tools);
340        assert!(!result.server.supports_resources);
341        assert!(!result.server.supports_prompts);
342        assert_eq!(result.tools.len(), 0);
343    }
344
345    #[test]
346    fn test_build_result_with_tools_not_detailed() {
347        let server_info = ServerInfo {
348            id: ServerId::new("test"),
349            name: "Test".to_string(),
350            version: "1.0.0".to_string(),
351            tools: vec![
352                ToolInfo {
353                    name: ToolName::new("tool1"),
354                    description: "First tool".to_string(),
355                    input_schema: json!({"type": "object"}),
356                    output_schema: None,
357                },
358                ToolInfo {
359                    name: ToolName::new("tool2"),
360                    description: "Second tool".to_string(),
361                    input_schema: json!({"type": "string"}),
362                    output_schema: Some(json!({"type": "boolean"})),
363                },
364            ],
365            capabilities: ServerCapabilities {
366                supports_tools: true,
367                supports_resources: true,
368                supports_prompts: true,
369            },
370        };
371
372        let result = build_result(&server_info, false);
373
374        assert_eq!(result.tools.len(), 2);
375        assert_eq!(result.tools[0].name, "tool1");
376        assert_eq!(result.tools[0].description, "First tool");
377        assert!(result.tools[0].input_schema.is_none());
378        assert!(result.tools[0].output_schema.is_none());
379
380        assert_eq!(result.tools[1].name, "tool2");
381        assert_eq!(result.tools[1].description, "Second tool");
382        assert!(result.tools[1].input_schema.is_none());
383        assert!(result.tools[1].output_schema.is_none());
384    }
385
386    #[test]
387    fn test_build_result_with_tools_detailed() {
388        let server_info = ServerInfo {
389            id: ServerId::new("test"),
390            name: "Test".to_string(),
391            version: "1.0.0".to_string(),
392            tools: vec![
393                ToolInfo {
394                    name: ToolName::new("tool1"),
395                    description: "First tool".to_string(),
396                    input_schema: json!({"type": "object", "properties": {"name": {"type": "string"}}}),
397                    output_schema: None,
398                },
399                ToolInfo {
400                    name: ToolName::new("tool2"),
401                    description: "Second tool".to_string(),
402                    input_schema: json!({"type": "string"}),
403                    output_schema: Some(json!({"type": "boolean"})),
404                },
405            ],
406            capabilities: ServerCapabilities {
407                supports_tools: true,
408                supports_resources: false,
409                supports_prompts: false,
410            },
411        };
412
413        let result = build_result(&server_info, true);
414
415        assert_eq!(result.tools.len(), 2);
416
417        // First tool - has input schema but no output schema
418        assert_eq!(result.tools[0].name, "tool1");
419        assert!(result.tools[0].input_schema.is_some());
420        assert_eq!(
421            result.tools[0].input_schema.as_ref().unwrap()["type"],
422            "object"
423        );
424        assert!(result.tools[0].output_schema.is_none());
425
426        // Second tool - has both input and output schemas
427        assert_eq!(result.tools[1].name, "tool2");
428        assert!(result.tools[1].input_schema.is_some());
429        assert_eq!(
430            result.tools[1].input_schema.as_ref().unwrap()["type"],
431            "string"
432        );
433        assert!(result.tools[1].output_schema.is_some());
434        assert_eq!(
435            result.tools[1].output_schema.as_ref().unwrap()["type"],
436            "boolean"
437        );
438    }
439
440    #[test]
441    fn test_build_tool_metadata_not_detailed() {
442        let tool_info = ToolInfo {
443            name: ToolName::new("send_message"),
444            description: "Sends a message".to_string(),
445            input_schema: json!({"type": "object"}),
446            output_schema: Some(json!({"type": "string"})),
447        };
448
449        let metadata = build_tool_metadata(&tool_info, false);
450
451        assert_eq!(metadata.name, "send_message");
452        assert_eq!(metadata.description, "Sends a message");
453        assert!(metadata.input_schema.is_none());
454        assert!(metadata.output_schema.is_none());
455    }
456
457    #[test]
458    fn test_build_tool_metadata_detailed() {
459        let tool_info = ToolInfo {
460            name: ToolName::new("send_message"),
461            description: "Sends a message".to_string(),
462            input_schema: json!({
463                "type": "object",
464                "properties": {
465                    "chat_id": {"type": "string"},
466                    "text": {"type": "string"}
467                }
468            }),
469            output_schema: Some(json!({"type": "string"})),
470        };
471
472        let metadata = build_tool_metadata(&tool_info, true);
473
474        assert_eq!(metadata.name, "send_message");
475        assert_eq!(metadata.description, "Sends a message");
476        assert!(metadata.input_schema.is_some());
477        assert_eq!(metadata.input_schema.as_ref().unwrap()["type"], "object");
478        assert!(metadata.output_schema.is_some());
479        assert_eq!(metadata.output_schema.as_ref().unwrap()["type"], "string");
480    }
481
482    #[test]
483    fn test_introspection_result_serialization() {
484        let result = IntrospectionResult {
485            server: ServerMetadata {
486                id: "test".to_string(),
487                name: "Test Server".to_string(),
488                version: "1.0.0".to_string(),
489                supports_tools: true,
490                supports_resources: false,
491                supports_prompts: false,
492            },
493            tools: vec![ToolDisplay {
494                name: "test_tool".to_string(),
495                description: "A test tool".to_string(),
496                input_schema: None,
497                output_schema: None,
498            }],
499        };
500
501        let json = serde_json::to_string(&result).unwrap();
502        assert!(json.contains("Test Server"));
503        assert!(json.contains("test_tool"));
504
505        // Schemas should not be in JSON when None
506        assert!(!json.contains("input_schema"));
507        assert!(!json.contains("output_schema"));
508    }
509
510    #[test]
511    fn test_introspection_result_serialization_with_schemas() {
512        let result = IntrospectionResult {
513            server: ServerMetadata {
514                id: "test".to_string(),
515                name: "Test Server".to_string(),
516                version: "1.0.0".to_string(),
517                supports_tools: true,
518                supports_resources: false,
519                supports_prompts: false,
520            },
521            tools: vec![ToolDisplay {
522                name: "test_tool".to_string(),
523                description: "A test tool".to_string(),
524                input_schema: Some(json!({"type": "object"})),
525                output_schema: Some(json!({"type": "string"})),
526            }],
527        };
528
529        let json = serde_json::to_string(&result).unwrap();
530        assert!(json.contains("input_schema"));
531        assert!(json.contains("output_schema"));
532        assert!(json.contains("\"type\":\"object\""));
533        assert!(json.contains("\"type\":\"string\""));
534    }
535
536    #[tokio::test]
537    async fn test_run_server_connection_failure() {
538        let result = run(
539            None,
540            Some("nonexistent-server-xyz".to_string()),
541            vec![],
542            vec![],
543            None,
544            None,
545            None,
546            vec![],
547            false,
548            None,
549            None,
550            OutputFormat::Json,
551        )
552        .await;
553
554        assert!(result.is_err());
555        let err_msg = result.unwrap_err().to_string();
556        assert!(err_msg.contains("failed to connect to server"));
557    }
558
559    // Note: build_server_config tests are in common.rs
560
561    #[test]
562    fn test_server_metadata_all_capabilities() {
563        let metadata = ServerMetadata {
564            id: "test".to_string(),
565            name: "Test".to_string(),
566            version: "2.0.0".to_string(),
567            supports_tools: true,
568            supports_resources: true,
569            supports_prompts: true,
570        };
571
572        assert!(metadata.supports_tools);
573        assert!(metadata.supports_resources);
574        assert!(metadata.supports_prompts);
575    }
576
577    #[test]
578    fn test_server_metadata_no_capabilities() {
579        let metadata = ServerMetadata {
580            id: "test".to_string(),
581            name: "Test".to_string(),
582            version: "1.0.0".to_string(),
583            supports_tools: false,
584            supports_resources: false,
585            supports_prompts: false,
586        };
587
588        assert!(!metadata.supports_tools);
589        assert!(!metadata.supports_resources);
590        assert!(!metadata.supports_prompts);
591    }
592
593    #[test]
594    fn test_tool_metadata_empty_description() {
595        let metadata = ToolDisplay {
596            name: "tool".to_string(),
597            description: String::new(),
598            input_schema: None,
599            output_schema: None,
600        };
601
602        assert_eq!(metadata.description, "");
603    }
604
605    #[test]
606    fn test_build_result_preserves_tool_order() {
607        let server_info = ServerInfo {
608            id: ServerId::new("test"),
609            name: "Test".to_string(),
610            version: "1.0.0".to_string(),
611            tools: vec![
612                ToolInfo {
613                    name: ToolName::new("alpha"),
614                    description: "A".to_string(),
615                    input_schema: json!({}),
616                    output_schema: None,
617                },
618                ToolInfo {
619                    name: ToolName::new("beta"),
620                    description: "B".to_string(),
621                    input_schema: json!({}),
622                    output_schema: None,
623                },
624                ToolInfo {
625                    name: ToolName::new("gamma"),
626                    description: "C".to_string(),
627                    input_schema: json!({}),
628                    output_schema: None,
629                },
630            ],
631            capabilities: ServerCapabilities {
632                supports_tools: true,
633                supports_resources: false,
634                supports_prompts: false,
635            },
636        };
637
638        let result = build_result(&server_info, false);
639
640        assert_eq!(result.tools.len(), 3);
641        assert_eq!(result.tools[0].name, "alpha");
642        assert_eq!(result.tools[1].name, "beta");
643        assert_eq!(result.tools[2].name, "gamma");
644    }
645
646    #[tokio::test]
647    async fn test_run_with_text_format() {
648        // Test that Text format output works correctly (compact JSON)
649        let result = run(
650            None,
651            Some("nonexistent-server".to_string()),
652            vec![],
653            vec![],
654            None,
655            None,
656            None,
657            vec![],
658            false,
659            None,
660            None,
661            OutputFormat::Text,
662        )
663        .await;
664
665        // Connection should fail but format handling should not panic
666        assert!(result.is_err());
667    }
668
669    #[tokio::test]
670    async fn test_run_with_pretty_format() {
671        // Test that Pretty format output works correctly (colorized)
672        let result = run(
673            None,
674            Some("nonexistent-server".to_string()),
675            vec![],
676            vec![],
677            None,
678            None,
679            None,
680            vec![],
681            false,
682            None,
683            None,
684            OutputFormat::Pretty,
685        )
686        .await;
687
688        // Connection should fail but format handling should not panic
689        assert!(result.is_err());
690    }
691
692    #[tokio::test]
693    async fn test_run_with_detailed_mode() {
694        // Test that detailed mode doesn't cause crashes even with connection failure
695        let result = run(
696            None,
697            Some("nonexistent-server".to_string()),
698            vec![],
699            vec![],
700            None,
701            None,
702            None,
703            vec![],
704            true, // detailed mode
705            None,
706            None,
707            OutputFormat::Json,
708        )
709        .await;
710
711        assert!(result.is_err());
712    }
713
714    #[tokio::test]
715    async fn test_run_http_transport() {
716        // Test HTTP transport with invalid URL
717        let result = run(
718            None,
719            None,
720            vec![],
721            vec![],
722            None,
723            Some("https://localhost:99999/invalid".to_string()),
724            None,
725            vec!["Authorization=Bearer test".to_string()],
726            false,
727            None,
728            None,
729            OutputFormat::Json,
730        )
731        .await;
732
733        assert!(result.is_err());
734        let err_msg = result.unwrap_err().to_string();
735        assert!(err_msg.contains("failed to connect to server"));
736    }
737
738    #[tokio::test]
739    async fn test_run_sse_transport() {
740        // Test SSE transport with invalid URL
741        let result = run(
742            None,
743            None,
744            vec![],
745            vec![],
746            None,
747            None,
748            Some("https://localhost:99999/sse".to_string()),
749            vec!["X-API-Key=test-key".to_string()],
750            false,
751            None,
752            None,
753            OutputFormat::Json,
754        )
755        .await;
756
757        assert!(result.is_err());
758        let err_msg = result.unwrap_err().to_string();
759        assert!(err_msg.contains("failed to connect to server"));
760    }
761
762    #[tokio::test]
763    async fn test_run_all_output_formats() {
764        // Test all output formats don't cause panics
765        for format in [OutputFormat::Json, OutputFormat::Text, OutputFormat::Pretty] {
766            let result = run(
767                None,
768                Some("nonexistent".to_string()),
769                vec![],
770                vec![],
771                None,
772                None,
773                None,
774                vec![],
775                false,
776                None,
777                None,
778                format,
779            )
780            .await;
781
782            assert!(result.is_err());
783        }
784    }
785
786    #[tokio::test]
787    async fn test_run_detailed_with_all_formats() {
788        // Test detailed mode with all output formats
789        for format in [OutputFormat::Json, OutputFormat::Text, OutputFormat::Pretty] {
790            let result = run(
791                None,
792                Some("nonexistent".to_string()),
793                vec![],
794                vec![],
795                None,
796                None,
797                None,
798                vec![],
799                true, // detailed
800                None,
801                None,
802                format,
803            )
804            .await;
805
806            assert!(result.is_err());
807        }
808    }
809
810    #[test]
811    fn test_build_result_empty_tools() {
812        let server_info = ServerInfo {
813            id: ServerId::new("empty"),
814            name: "Empty Server".to_string(),
815            version: "0.1.0".to_string(),
816            tools: vec![],
817            capabilities: ServerCapabilities {
818                supports_tools: false,
819                supports_resources: false,
820                supports_prompts: false,
821            },
822        };
823
824        let result = build_result(&server_info, false);
825
826        assert_eq!(result.server.name, "Empty Server");
827        assert_eq!(result.tools.len(), 0);
828        assert!(!result.server.supports_tools);
829    }
830
831    #[test]
832    fn test_build_result_many_tools() {
833        // Test with many tools to ensure no performance issues
834        let tools: Vec<ToolInfo> = (0..100)
835            .map(|i| ToolInfo {
836                name: ToolName::new(&format!("tool_{i}")),
837                description: format!("Tool number {i}"),
838                input_schema: json!({"type": "object"}),
839                output_schema: Some(json!({"type": "string"})),
840            })
841            .collect();
842
843        let server_info = ServerInfo {
844            id: ServerId::new("many-tools"),
845            name: "Server with many tools".to_string(),
846            version: "1.0.0".to_string(),
847            tools,
848            capabilities: ServerCapabilities {
849                supports_tools: true,
850                supports_resources: true,
851                supports_prompts: true,
852            },
853        };
854
855        let result = build_result(&server_info, true);
856
857        assert_eq!(result.tools.len(), 100);
858        assert_eq!(result.tools[0].name, "tool_0");
859        assert_eq!(result.tools[99].name, "tool_99");
860        // In detailed mode, schemas should be present
861        assert!(result.tools[0].input_schema.is_some());
862        assert!(result.tools[0].output_schema.is_some());
863    }
864
865    #[test]
866    fn test_build_tool_metadata_complex_schema() {
867        let tool_info = ToolInfo {
868            name: ToolName::new("complex_tool"),
869            description: "Tool with complex schema".to_string(),
870            input_schema: json!({
871                "type": "object",
872                "properties": {
873                    "name": {"type": "string", "minLength": 1},
874                    "age": {"type": "integer", "minimum": 0},
875                    "tags": {
876                        "type": "array",
877                        "items": {"type": "string"}
878                    }
879                },
880                "required": ["name"]
881            }),
882            output_schema: Some(json!({
883                "type": "object",
884                "properties": {
885                    "success": {"type": "boolean"},
886                    "message": {"type": "string"}
887                }
888            })),
889        };
890
891        let metadata = build_tool_metadata(&tool_info, true);
892
893        assert_eq!(metadata.name, "complex_tool");
894        assert!(metadata.input_schema.is_some());
895        assert!(metadata.output_schema.is_some());
896
897        let input = metadata.input_schema.as_ref().unwrap();
898        assert_eq!(input["type"], "object");
899        assert!(input["properties"]["name"].is_object());
900        assert!(input["properties"]["tags"]["items"].is_object());
901    }
902
903    #[test]
904    fn test_introspection_result_clone() {
905        let result = IntrospectionResult {
906            server: ServerMetadata {
907                id: "test".to_string(),
908                name: "Test".to_string(),
909                version: "1.0.0".to_string(),
910                supports_tools: true,
911                supports_resources: false,
912                supports_prompts: false,
913            },
914            tools: vec![],
915        };
916
917        // Test Clone implementation
918        let cloned = result.clone();
919        assert_eq!(cloned.server.id, result.server.id);
920        assert_eq!(cloned.server.name, result.server.name);
921    }
922
923    #[test]
924    fn test_server_metadata_serialization_all_fields() {
925        let metadata = ServerMetadata {
926            id: "test-id".to_string(),
927            name: "Test Server".to_string(),
928            version: "2.1.0".to_string(),
929            supports_tools: true,
930            supports_resources: true,
931            supports_prompts: true,
932        };
933
934        let json = serde_json::to_value(&metadata).unwrap();
935
936        assert_eq!(json["id"], "test-id");
937        assert_eq!(json["name"], "Test Server");
938        assert_eq!(json["version"], "2.1.0");
939        assert_eq!(json["supports_tools"], true);
940        assert_eq!(json["supports_resources"], true);
941        assert_eq!(json["supports_prompts"], true);
942    }
943
944    #[test]
945    fn test_tool_metadata_serialization_without_schemas() {
946        let metadata = ToolDisplay {
947            name: "simple_tool".to_string(),
948            description: "A simple tool".to_string(),
949            input_schema: None,
950            output_schema: None,
951        };
952
953        let json = serde_json::to_string(&metadata).unwrap();
954
955        // Fields with None should not be serialized (skip_serializing_if)
956        assert!(!json.contains("input_schema"));
957        assert!(!json.contains("output_schema"));
958        assert!(json.contains("simple_tool"));
959        assert!(json.contains("A simple tool"));
960    }
961
962    #[test]
963    fn test_tool_metadata_long_description() {
964        let long_description = "A".repeat(1000);
965        let metadata = ToolDisplay {
966            name: "tool".to_string(),
967            description: long_description.clone(),
968            input_schema: None,
969            output_schema: None,
970        };
971
972        // Should handle long descriptions without issues
973        assert_eq!(metadata.description.len(), 1000);
974        let json = serde_json::to_string(&metadata).unwrap();
975        assert!(json.contains(&long_description));
976    }
977
978    #[test]
979    fn test_build_result_mixed_capabilities() {
980        let server_info = ServerInfo {
981            id: ServerId::new("mixed"),
982            name: "Mixed Server".to_string(),
983            version: "1.0.0".to_string(),
984            tools: vec![ToolInfo {
985                name: ToolName::new("tool1"),
986                description: "First".to_string(),
987                input_schema: json!({}),
988                output_schema: None,
989            }],
990            capabilities: ServerCapabilities {
991                supports_tools: true,
992                supports_resources: true,
993                supports_prompts: false, // Mixed capabilities
994            },
995        };
996
997        let result = build_result(&server_info, false);
998
999        assert!(result.server.supports_tools);
1000        assert!(result.server.supports_resources);
1001        assert!(!result.server.supports_prompts);
1002    }
1003
1004    #[tokio::test]
1005    async fn test_run_from_config_not_found() {
1006        let result = run(
1007            Some("nonexistent-server-xyz".to_string()),
1008            None,
1009            vec![],
1010            vec![],
1011            None,
1012            None,
1013            None,
1014            vec![],
1015            false,
1016            None,
1017            None,
1018            OutputFormat::Json,
1019        )
1020        .await;
1021
1022        assert!(result.is_err());
1023        let err_msg = result.unwrap_err().to_string();
1024        assert!(
1025            err_msg.contains("not found in")
1026                || err_msg.contains("failed to read MCP config")
1027                || err_msg.contains("mcp.json"),
1028            "Expected config-related error, got: {err_msg}"
1029        );
1030    }
1031
1032    #[tokio::test]
1033    async fn test_run_from_config_takes_priority() {
1034        // When from_config is Some, it should be used for config loading
1035        // (server arg should be None due to clap conflicts, but we test the logic)
1036        let result = run(
1037            Some("test-server".to_string()),
1038            None, // server is None when from_config is used
1039            vec![],
1040            vec![],
1041            None,
1042            None,
1043            None,
1044            vec![],
1045            false,
1046            None,
1047            None,
1048            OutputFormat::Json,
1049        )
1050        .await;
1051
1052        // Should fail because config doesn't exist, not because of server
1053        assert!(result.is_err());
1054        let err_msg = result.unwrap_err().to_string();
1055        // Should try to load from config, not use manual server
1056        assert!(
1057            err_msg.contains("MCP config") || err_msg.contains("test-server"),
1058            "Should attempt config loading: {err_msg}"
1059        );
1060    }
1061
1062    #[tokio::test]
1063    async fn test_run_manual_mode_backward_compatible() {
1064        // Existing behavior: from_config = None, use server arg
1065        let result = run(
1066            None, // from_config
1067            Some("test-server-direct".to_string()),
1068            vec![],
1069            vec![],
1070            None,
1071            None,
1072            None,
1073            vec![],
1074            false,
1075            None,
1076            None,
1077            OutputFormat::Json,
1078        )
1079        .await;
1080
1081        assert!(result.is_err());
1082        let err_msg = result.unwrap_err().to_string();
1083        // Should fail with connection error, not config error
1084        assert!(
1085            err_msg.contains("failed to connect") || err_msg.contains("test-server-direct"),
1086            "Should try direct connection: {err_msg}"
1087        );
1088    }
1089
1090    #[tokio::test]
1091    async fn test_run_zero_connect_timeout_override_rejected_by_validation() {
1092        // A zero override must surface the same connect_timeout validation
1093        // error as the mcp.json path, not just a generic connection failure.
1094        let result = run(
1095            None,
1096            Some("nonexistent-server-timeout-test".to_string()),
1097            vec![],
1098            vec![],
1099            None,
1100            None,
1101            None,
1102            vec![],
1103            false,
1104            Some(0),
1105            None,
1106            OutputFormat::Json,
1107        )
1108        .await;
1109
1110        assert!(result.is_err());
1111        let err = result.unwrap_err();
1112        let chain_msg = err
1113            .chain()
1114            .map(ToString::to_string)
1115            .collect::<Vec<_>>()
1116            .join(" | ");
1117        assert!(
1118            chain_msg.contains("greater than zero"),
1119            "expected connect_timeout validation error in the error chain, got: {chain_msg}"
1120        );
1121    }
1122
1123    #[tokio::test]
1124    async fn test_run_with_valid_timeout_overrides_reaches_connection_attempt() {
1125        // Valid overrides must not be rejected before the connection attempt.
1126        let result = run(
1127            None,
1128            Some("nonexistent-server-timeout-test-2".to_string()),
1129            vec![],
1130            vec![],
1131            None,
1132            None,
1133            None,
1134            vec![],
1135            false,
1136            Some(5),
1137            Some(90),
1138            OutputFormat::Json,
1139        )
1140        .await;
1141
1142        assert!(result.is_err());
1143        let err_msg = result.unwrap_err().to_string();
1144        assert!(err_msg.contains("failed to connect to server"));
1145    }
1146}