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 crate::formatters::emit(&result, output_format, ExitCode::SUCCESS)
221 .context("failed to format introspection results")
222}
223
224#[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
276fn 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 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 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 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 #[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 let source = stdio_source("nonexistent-server");
667 let result = run(source, false, OutputFormat::Text).await;
668
669 assert!(result.is_err());
671 }
672
673 #[tokio::test]
674 async fn test_run_with_pretty_format() {
675 let source = stdio_source("nonexistent-server");
677 let result = run(source, false, OutputFormat::Pretty).await;
678
679 assert!(result.is_err());
681 }
682
683 #[tokio::test]
684 async fn test_run_with_detailed_mode() {
685 let source = stdio_source("nonexistent-server");
687 let result = run(source, true, OutputFormat::Json).await; assert!(result.is_err());
690 }
691
692 #[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 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 #[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 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 for format in [OutputFormat::Json, OutputFormat::Text, OutputFormat::Pretty] {
764 let source = stdio_source("nonexistent");
765 let result = run(source, true, format).await; 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 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 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 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 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 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, },
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 let source = config_source("test-server");
986 let result = run(source, false, OutputFormat::Json).await;
987
988 assert!(result.is_err());
990 let err_msg = result.unwrap_err().to_string();
991 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 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 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 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 #[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 #[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 #[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 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}