1use 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#[derive(Debug, Clone, Serialize)]
37pub struct IntrospectionResult {
38 pub server: ServerMetadata,
40 pub tools: Vec<ToolDisplay>,
42}
43
44#[derive(Debug, Clone, Serialize)]
66pub struct ServerMetadata {
67 pub id: String,
69 pub name: String,
71 pub version: String,
73 pub supports_tools: bool,
75 pub supports_resources: bool,
77 pub supports_prompts: bool,
79}
80
81#[derive(Debug, Clone, Serialize)]
101pub struct ToolDisplay {
102 pub name: String,
104 pub description: String,
106 #[serde(skip_serializing_if = "Option::is_none")]
108 pub input_schema: Option<serde_json::Value>,
109 #[serde(skip_serializing_if = "Option::is_none")]
111 pub output_schema: Option<serde_json::Value>,
112}
113
114pub async fn run(
186 source: ServerSource,
187 detailed: bool,
188 output_format: OutputFormat,
189) -> Result<ExitCode> {
190 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 let mut introspector = Introspector::new();
200
201 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 let result = build_result(&server_info, detailed);
218
219 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#[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
280fn 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 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 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 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 #[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 let source = stdio_source("nonexistent-server");
671 let result = run(source, false, OutputFormat::Text).await;
672
673 assert!(result.is_err());
675 }
676
677 #[tokio::test]
678 async fn test_run_with_pretty_format() {
679 let source = stdio_source("nonexistent-server");
681 let result = run(source, false, OutputFormat::Pretty).await;
682
683 assert!(result.is_err());
685 }
686
687 #[tokio::test]
688 async fn test_run_with_detailed_mode() {
689 let source = stdio_source("nonexistent-server");
691 let result = run(source, true, OutputFormat::Json).await; assert!(result.is_err());
694 }
695
696 #[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 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 #[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 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 for format in [OutputFormat::Json, OutputFormat::Text, OutputFormat::Pretty] {
768 let source = stdio_source("nonexistent");
769 let result = run(source, true, format).await; 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 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 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 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 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 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, },
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 let source = config_source("test-server");
990 let result = run(source, false, OutputFormat::Json).await;
991
992 assert!(result.is_err());
994 let err_msg = result.unwrap_err().to_string();
995 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 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 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 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 #[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 #[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 #[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 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}