From 5462fe3f965ab509915a737f2d9a1214f76ac37a Mon Sep 17 00:00:00 2001 From: Rodrigo Matiazo Date: Tue, 10 Mar 2026 15:49:52 -0400 Subject: [PATCH 1/5] 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/5] 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) { From 1a16a76d55809337eeeb492046aaf1883e67910a Mon Sep 17 00:00:00 2001 From: Grant Harris <96964444+gwharris7@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:18:45 -0700 Subject: [PATCH 3/5] Address PR 212 review comments: refactor timeout helper and harden tests - Extract GetValidatedInitializationTimeout internal static helper so the timeout TimeSpan is computed once and reused for both HttpClient.Timeout and McpClientOptions.InitializationTimeout. - Use nameof(ToolOptions.McpClientInitializationTimeoutSeconds) as the ArgumentOutOfRangeException ParamName so the offending property is obvious to consumers. - Loosen the GetMcpClientToolsAsync exception assertion to accept either a direct ArgumentOutOfRangeException or one wrapped by InvalidOperationException so the test is decoupled from the outer catch behavior. - Remove the unused IConfiguration mock field from the timeout test class. - Add direct unit coverage for GetValidatedInitializationTimeout (null, valid, and invalid inputs) so the behavioral change is verifiable without a McpClientFactory seam. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../McpClientInitializationTimeoutTests.cs | 53 +++++++++++++++++-- .../McpToolServerConfigurationService.cs | 50 ++++++++++++----- 2 files changed, 85 insertions(+), 18 deletions(-) diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Tests/Services/McpClientInitializationTimeoutTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Tests/Services/McpClientInitializationTimeoutTests.cs index f0f258e1..26800ff9 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Tests/Services/McpClientInitializationTimeoutTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Tests/Services/McpClientInitializationTimeoutTests.cs @@ -20,14 +20,12 @@ namespace Microsoft.Agents.A365.Tooling.Tests.Services 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(); @@ -113,9 +111,54 @@ public async Task GetMcpClientToolsAsync_InvalidTimeoutValue_ThrowsArgumentOutOf 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(); + // 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] diff --git a/src/Tooling/Core/Services/McpToolServerConfigurationService.cs b/src/Tooling/Core/Services/McpToolServerConfigurationService.cs index 1c6b5e81..fd0ea672 100644 --- a/src/Tooling/Core/Services/McpToolServerConfigurationService.cs +++ b/src/Tooling/Core/Services/McpToolServerConfigurationService.cs @@ -521,22 +521,17 @@ 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 (toolOptions.McpClientInitializationTimeoutSeconds.HasValue) + if (initializationTimeout.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); + httpClient.Timeout = initializationTimeout.Value; } var clientTransport = new SseClientTransport(options, httpClient); @@ -544,11 +539,11 @@ private async Task CreateMcpClientWithAuthHandlers(ITurnContext turn try { // Only pass McpClientOptions when a custom timeout is set to preserve default SDK behavior - if (toolOptions.McpClientInitializationTimeoutSeconds.HasValue) + if (initializationTimeout.HasValue) { var clientOptions = new McpClientOptions { - InitializationTimeout = TimeSpan.FromSeconds(toolOptions.McpClientInitializationTimeoutSeconds.Value), + InitializationTimeout = initializationTimeout.Value, }; return await McpClientFactory.CreateAsync(clientTransport, clientOptions, loggerFactory: this._loggerFactory); @@ -562,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. From 86b824228c174d84eea62cfa528abf9f55445f84 Mon Sep 17 00:00:00 2001 From: Grant Harris <96964444+gwharris7@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:25:37 -0700 Subject: [PATCH 4/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Services/McpClientInitializationTimeoutTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Tests/Services/McpClientInitializationTimeoutTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Tests/Services/McpClientInitializationTimeoutTests.cs index 26800ff9..d712ca66 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Tests/Services/McpClientInitializationTimeoutTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Tests/Services/McpClientInitializationTimeoutTests.cs @@ -6,7 +6,6 @@ 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; From 546e8c6592dcfc86fb25b987913f8e946d4f3691 Mon Sep 17 00:00:00 2001 From: Grant Harris <96964444+gwharris7@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:54:48 -0700 Subject: [PATCH 5/5] chore: retrigger CodeQL checks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>