Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Unit tests for McpClientInitializationTimeoutSeconds in ToolOptions
/// and its application in McpToolServerConfigurationService.
/// </summary>
public class McpClientInitializationTimeoutTests
{
private readonly Mock<ILogger<IMcpToolServerConfigurationService>> _loggerMock;
private readonly Mock<IConfiguration> _configurationMock;
private readonly Mock<IServiceProvider> _serviceProviderMock;
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;

public McpClientInitializationTimeoutTests()
{
_loggerMock = new Mock<ILogger<IMcpToolServerConfigurationService>>();
_configurationMock = new Mock<IConfiguration>();
_serviceProviderMock = new Mock<IServiceProvider>();
_httpClientFactoryMock = new Mock<IHttpClientFactory>();

_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>()))
.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<IConfiguration>();
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<ITurnContext>();

// Act
Func<Task> 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<InvalidOperationException>();
ex.WithInnerException<ArgumentOutOfRangeException>();
}

[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);
}
}
}
10 changes: 10 additions & 0 deletions src/Tooling/Core/Models/ToolOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,15 @@ public class ToolOptions
/// Gets or sets the user agent configuration for this orchestrator.
/// </summary>
public IUserAgentConfiguration? UserAgentConfiguration { get; set; }

/// <summary>
/// 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.
Comment on lines +19 to +23
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new McpClientInitializationTimeoutSeconds property doesn’t document or enforce an allowed range. Since invalid values (<= 0, extremely large) can cause runtime exceptions when converted to a TimeSpan or applied to HttpClient.Timeout, it would help to document the expected bounds here (and ideally validate it at the call site).

Copilot uses AI. Check for mistakes.
/// Valid range: 1 to 600 seconds. Values outside this range will throw <see cref="ArgumentOutOfRangeException"/>.
/// </summary>
public int? McpClientInitializationTimeoutSeconds { get; set; }
}
}
26 changes: 26 additions & 0 deletions src/Tooling/Core/Services/McpToolServerConfigurationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -503,10 +503,36 @@ private async Task<IMcpClient> 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);
}
Comment on lines +506 to +519
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

McpClientInitializationTimeoutSeconds is used directly to set HttpClient.Timeout and McpClientOptions.InitializationTimeout. If a consumer sets this to 0, a negative value, or a very large value, TimeSpan.FromSeconds(...) / HttpClient.Timeout can throw (or effectively make requests immediately time out). Consider validating the value (e.g., > 0 and within TimeSpan/HttpClient supported bounds) and throwing an ArgumentOutOfRangeException with a clear message.

Copilot uses AI. Check for mistakes.

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)
Expand Down
Loading