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