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