From 5462fe3f965ab509915a737f2d9a1214f76ac37a Mon Sep 17 00:00:00 2001 From: Rodrigo Matiazo Date: Tue, 10 Mar 2026 15:49:52 -0400 Subject: [PATCH 1/2] Make MCP client initialization timeout configurable via ToolOptions Add nullable McpClientInitializationTimeoutSeconds property to ToolOptions so consumers can override the MCP client initialization timeout. When null (default), the MCP SDK default is used. When set, both McpClientOptions and HttpClient.Timeout are configured to match. Co-Authored-By: Claude Code --- src/Tooling/Core/Models/ToolOptions.cs | 9 +++++++++ .../Services/McpToolServerConfigurationService.cs | 15 ++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/Tooling/Core/Models/ToolOptions.cs b/src/Tooling/Core/Models/ToolOptions.cs index 3a63f213..fdad3c00 100644 --- a/src/Tooling/Core/Models/ToolOptions.cs +++ b/src/Tooling/Core/Models/ToolOptions.cs @@ -14,5 +14,14 @@ 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. + /// + public int? McpClientInitializationTimeoutSeconds { get; set; } } } diff --git a/src/Tooling/Core/Services/McpToolServerConfigurationService.cs b/src/Tooling/Core/Services/McpToolServerConfigurationService.cs index db26decb..0467007c 100644 --- a/src/Tooling/Core/Services/McpToolServerConfigurationService.cs +++ b/src/Tooling/Core/Services/McpToolServerConfigurationService.cs @@ -503,11 +503,24 @@ 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) + { + httpClient.Timeout = TimeSpan.FromSeconds(toolOptions.McpClientInitializationTimeoutSeconds.Value); + } + var clientTransport = new SseClientTransport(options, httpClient); try { - return await McpClientFactory.CreateAsync(clientTransport, loggerFactory: this._loggerFactory); + var clientOptions = toolOptions.McpClientInitializationTimeoutSeconds.HasValue + ? new McpClientOptions + { + InitializationTimeout = TimeSpan.FromSeconds(toolOptions.McpClientInitializationTimeoutSeconds.Value), + } + : new McpClientOptions(); + + return await McpClientFactory.CreateAsync(clientTransport, clientOptions, loggerFactory: this._loggerFactory); } catch (Exception ex) { From 64bb70cc15aa14fe943bd8db45de6f0560d9498b Mon Sep 17 00:00:00 2001 From: Rodrigo Matiazo Date: Wed, 11 Mar 2026 16:49:10 -0400 Subject: [PATCH 2/2] Address PR review feedback: validation, null behavior, tests - Add input validation (1-600s range) with ArgumentOutOfRangeException - Document valid range in ToolOptions XML docs - Preserve original SDK behavior when timeout is null: call McpClientFactory.CreateAsync without McpClientOptions instead of passing a default instance - Add 13 unit tests covering: null default, valid/invalid values, ArgumentOutOfRangeException for out-of-bounds inputs Co-Authored-By: Claude Code --- .../McpClientInitializationTimeoutTests.cs | 139 ++++++++++++++++++ src/Tooling/Core/Models/ToolOptions.cs | 1 + .../McpToolServerConfigurationService.cs | 25 +++- 3 files changed, 159 insertions(+), 6 deletions(-) create mode 100644 src/Tests/Microsoft.Agents.A365.Tooling.Tests/Services/McpClientInitializationTimeoutTests.cs 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 fdad3c00..ade9bf3b 100644 --- a/src/Tooling/Core/Models/ToolOptions.cs +++ b/src/Tooling/Core/Models/ToolOptions.cs @@ -21,6 +21,7 @@ public class ToolOptions /// 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 0467007c..5fdc604d 100644 --- a/src/Tooling/Core/Services/McpToolServerConfigurationService.cs +++ b/src/Tooling/Core/Services/McpToolServerConfigurationService.cs @@ -506,21 +506,34 @@ private async Task CreateMcpClientWithAuthHandlers(ITurnContext turn // Apply custom timeout only when explicitly configured if (toolOptions.McpClientInitializationTimeoutSeconds.HasValue) { - httpClient.Timeout = TimeSpan.FromSeconds(toolOptions.McpClientInitializationTimeoutSeconds.Value); + 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 { - var clientOptions = toolOptions.McpClientInitializationTimeoutSeconds.HasValue - ? new McpClientOptions + // 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), - } - : new McpClientOptions(); + }; + + return await McpClientFactory.CreateAsync(clientTransport, clientOptions, loggerFactory: this._loggerFactory); + } - return await McpClientFactory.CreateAsync(clientTransport, clientOptions, loggerFactory: this._loggerFactory); + return await McpClientFactory.CreateAsync(clientTransport, loggerFactory: this._loggerFactory); } catch (Exception ex) {