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..f0f258e1 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Tests/Services/McpClientInitializationTimeoutTests.cs @@ -0,0 +1,139 @@ +// 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.Http; +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 _configurationMock; + private readonly Mock _serviceProviderMock; + private readonly Mock _httpClientFactoryMock; + + public McpClientInitializationTimeoutTests() + { + _loggerMock = new Mock>(); + _configurationMock = 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 - ArgumentOutOfRangeException is wrapped in InvalidOperationException by the catch block + var ex = await act.Should().ThrowAsync(); + ex.WithInnerException(); + } + + [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 db26decb..5fdc604d 100644 --- a/src/Tooling/Core/Services/McpToolServerConfigurationService.cs +++ b/src/Tooling/Core/Services/McpToolServerConfigurationService.cs @@ -503,10 +503,36 @@ private async Task CreateMcpClientWithAuthHandlers(ITurnContext turn // Create HTTP client with the authentication handler chain var httpClient = new HttpClient(loggingHandler); + // Apply custom timeout only when explicitly configured + if (toolOptions.McpClientInitializationTimeoutSeconds.HasValue) + { + var timeoutSeconds = toolOptions.McpClientInitializationTimeoutSeconds.Value; + if (timeoutSeconds < 1 || timeoutSeconds > 600) + { + throw new ArgumentOutOfRangeException( + nameof(toolOptions), + timeoutSeconds, + "McpClientInitializationTimeoutSeconds must be between 1 and 600 seconds."); + } + + httpClient.Timeout = TimeSpan.FromSeconds(timeoutSeconds); + } + var clientTransport = new SseClientTransport(options, httpClient); try { + // Only pass McpClientOptions when a custom timeout is set to preserve default SDK behavior + if (toolOptions.McpClientInitializationTimeoutSeconds.HasValue) + { + var clientOptions = new McpClientOptions + { + InitializationTimeout = TimeSpan.FromSeconds(toolOptions.McpClientInitializationTimeoutSeconds.Value), + }; + + return await McpClientFactory.CreateAsync(clientTransport, clientOptions, loggerFactory: this._loggerFactory); + } + return await McpClientFactory.CreateAsync(clientTransport, loggerFactory: this._loggerFactory); } catch (Exception ex)