diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Tests/Services/McpClientInitializationTimeoutTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Tests/Services/McpClientInitializationTimeoutTests.cs new file mode 100644 index 00000000..d712ca66 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Tests/Services/McpClientInitializationTimeoutTests.cs @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.Tooling.Models; +using Microsoft.Agents.A365.Tooling.Services; +using Microsoft.Agents.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Microsoft.Agents.A365.Tooling.Tests.Services +{ + /// + /// Unit tests for McpClientInitializationTimeoutSeconds in ToolOptions + /// and its application in McpToolServerConfigurationService. + /// + public class McpClientInitializationTimeoutTests + { + private readonly Mock> _loggerMock; + private readonly Mock _serviceProviderMock; + private readonly Mock _httpClientFactoryMock; + + public McpClientInitializationTimeoutTests() + { + _loggerMock = new Mock>(); + _serviceProviderMock = new Mock(); + _httpClientFactoryMock = new Mock(); + + _httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny())) + .Returns(new HttpClient()); + } + + [Fact] + public void ToolOptions_McpClientInitializationTimeoutSeconds_DefaultsToNull() + { + // Arrange & Act + var options = new ToolOptions(); + + // Assert + options.McpClientInitializationTimeoutSeconds.Should().BeNull(); + } + + [Fact] + public void ToolOptions_McpClientInitializationTimeoutSeconds_CanBeSetToValidValue() + { + // Arrange & Act + var options = new ToolOptions + { + McpClientInitializationTimeoutSeconds = 180 + }; + + // Assert + options.McpClientInitializationTimeoutSeconds.Should().Be(180); + } + + [Fact] + public void ToolOptions_McpClientInitializationTimeoutSeconds_CanBeSetToNull() + { + // Arrange + var options = new ToolOptions + { + McpClientInitializationTimeoutSeconds = 120 + }; + + // Act + options.McpClientInitializationTimeoutSeconds = null; + + // Assert + options.McpClientInitializationTimeoutSeconds.Should().BeNull(); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-100)] + [InlineData(601)] + [InlineData(int.MaxValue)] + public async Task GetMcpClientToolsAsync_InvalidTimeoutValue_ThrowsArgumentOutOfRangeException(int invalidTimeout) + { + // Arrange + var configMock = new Mock(); + configMock.Setup(c => c["ASPNETCORE_ENVIRONMENT"]).Returns("Development"); + + var service = new McpToolServerConfigurationService( + _loggerMock.Object, + configMock.Object, + _serviceProviderMock.Object, + _httpClientFactoryMock.Object); + + var serverConfig = new MCPServerConfig + { + mcpServerName = "test_server", + url = "https://localhost:52856/agents/servers/test_server", + id = "test-id", + scope = "test-scope", + audience = "test-audience", + publisher = "test-publisher", + }; + + var toolOptions = new ToolOptions + { + McpClientInitializationTimeoutSeconds = invalidTimeout + }; + + var turnContextMock = new Mock(); + + // Act + Func act = async () => await service.GetMcpClientToolsAsync( + turnContextMock.Object, serverConfig, "test-token", toolOptions); + + // Assert - invalid timeout should surface an ArgumentOutOfRangeException either directly + // or wrapped by a higher-level InvalidOperationException depending on the call path. + var exception = await Record.ExceptionAsync(act); + exception.Should().NotBeNull(); + (exception is ArgumentOutOfRangeException + || exception is InvalidOperationException { InnerException: ArgumentOutOfRangeException }) + .Should().BeTrue("an invalid timeout should result in an ArgumentOutOfRangeException, either directly or wrapped"); + } + + [Fact] + public void GetValidatedInitializationTimeout_NullInput_ReturnsNull() + { + // Act + var result = McpToolServerConfigurationService.GetValidatedInitializationTimeout(null); + + // Assert + result.Should().BeNull(); + } + + [Theory] + [InlineData(1)] + [InlineData(60)] + [InlineData(120)] + [InlineData(300)] + [InlineData(600)] + public void GetValidatedInitializationTimeout_ValidInput_ReturnsMatchingTimeSpan(int seconds) + { + // Act + var result = McpToolServerConfigurationService.GetValidatedInitializationTimeout(seconds); + + // Assert + result.Should().Be(TimeSpan.FromSeconds(seconds)); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-100)] + [InlineData(601)] + [InlineData(int.MaxValue)] + public void GetValidatedInitializationTimeout_InvalidInput_ThrowsArgumentOutOfRangeException(int invalidSeconds) + { + // Act + Action act = () => McpToolServerConfigurationService.GetValidatedInitializationTimeout(invalidSeconds); + + // Assert + act.Should().Throw() + .Which.ParamName.Should().Be(nameof(ToolOptions.McpClientInitializationTimeoutSeconds)); + } + + [Theory] + [InlineData(1)] + [InlineData(60)] + [InlineData(120)] + [InlineData(300)] + [InlineData(600)] + public void ToolOptions_McpClientInitializationTimeoutSeconds_AcceptsValidValues(int validTimeout) + { + // Arrange & Act + var options = new ToolOptions + { + McpClientInitializationTimeoutSeconds = validTimeout + }; + + // Assert + options.McpClientInitializationTimeoutSeconds.Should().Be(validTimeout); + } + } +} diff --git a/src/Tooling/Core/Models/ToolOptions.cs b/src/Tooling/Core/Models/ToolOptions.cs index 3a63f213..ade9bf3b 100644 --- a/src/Tooling/Core/Models/ToolOptions.cs +++ b/src/Tooling/Core/Models/ToolOptions.cs @@ -14,5 +14,15 @@ public class ToolOptions /// Gets or sets the user agent configuration for this orchestrator. /// public IUserAgentConfiguration? UserAgentConfiguration { get; set; } + + /// + /// Gets or sets the timeout in seconds for MCP client initialization. + /// This includes the time for the MCP protocol handshake (initialize/initialized exchange) + /// and the underlying HTTP connection. Increase this value if the MCP server performs + /// slow operations during initialization (e.g., token exchanges in test environments). + /// When null, the MCP SDK default timeout is used. + /// Valid range: 1 to 600 seconds. Values outside this range will throw . + /// + public int? McpClientInitializationTimeoutSeconds { get; set; } } } diff --git a/src/Tooling/Core/Services/McpToolServerConfigurationService.cs b/src/Tooling/Core/Services/McpToolServerConfigurationService.cs index f5179dab..fd0ea672 100644 --- a/src/Tooling/Core/Services/McpToolServerConfigurationService.cs +++ b/src/Tooling/Core/Services/McpToolServerConfigurationService.cs @@ -521,13 +521,34 @@ private async Task CreateMcpClientWithAuthHandlers(ITurnContext turn TransportMode = HttpTransportMode.AutoDetect, }; + // Validate and compute the initialization timeout once so HttpClient.Timeout + // and McpClientOptions.InitializationTimeout always agree. + var initializationTimeout = GetValidatedInitializationTimeout(toolOptions.McpClientInitializationTimeoutSeconds); + // Create HTTP client with the authentication handler chain var httpClient = new HttpClient(loggingHandler); + // Apply custom timeout only when explicitly configured + if (initializationTimeout.HasValue) + { + httpClient.Timeout = initializationTimeout.Value; + } + var clientTransport = new SseClientTransport(options, httpClient); try { + // Only pass McpClientOptions when a custom timeout is set to preserve default SDK behavior + if (initializationTimeout.HasValue) + { + var clientOptions = new McpClientOptions + { + InitializationTimeout = initializationTimeout.Value, + }; + + return await McpClientFactory.CreateAsync(clientTransport, clientOptions, loggerFactory: this._loggerFactory); + } + return await McpClientFactory.CreateAsync(clientTransport, loggerFactory: this._loggerFactory); } catch (Exception ex) @@ -536,6 +557,35 @@ private async Task CreateMcpClientWithAuthHandlers(ITurnContext turn } } + /// + /// Validates and converts it + /// to a . Returns null when no custom timeout is configured so + /// callers can preserve the MCP SDK default behavior. + /// + /// The configured timeout in seconds, or null to use the SDK default. + /// The validated timeout as a , or null when not configured. + /// + /// Thrown when is set but falls outside the supported range of 1 to 600 seconds. + /// + internal static TimeSpan? GetValidatedInitializationTimeout(int? timeoutSeconds) + { + if (!timeoutSeconds.HasValue) + { + return null; + } + + var value = timeoutSeconds.Value; + if (value < 1 || value > 600) + { + throw new ArgumentOutOfRangeException( + nameof(ToolOptions.McpClientInitializationTimeoutSeconds), + value, + "McpClientInitializationTimeoutSeconds must be between 1 and 600 seconds."); + } + + return TimeSpan.FromSeconds(value); + } + /// /// Attaches a per-audience Bearer token to each server's /// dictionary.