Skip to content
Merged
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,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;
Comment thread
gwharris7 marked this conversation as resolved.
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<IServiceProvider> _serviceProviderMock;
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;

public McpClientInitializationTimeoutTests()
{
_loggerMock = new Mock<ILogger<IMcpToolServerConfigurationService>>();
_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 - 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<ArgumentOutOfRangeException>()
.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);
}
}
}
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 thread
matiazo marked this conversation as resolved.
/// Valid range: 1 to 600 seconds. Values outside this range will throw <see cref="ArgumentOutOfRangeException"/>.
/// </summary>
public int? McpClientInitializationTimeoutSeconds { get; set; }
}
}
50 changes: 50 additions & 0 deletions src/Tooling/Core/Services/McpToolServerConfigurationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -521,13 +521,34 @@ private async Task<IMcpClient> 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);
Comment thread
gwharris7 marked this conversation as resolved.

// 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;
}
Comment thread
matiazo marked this conversation as resolved.

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,
};
Comment thread
gwharris7 marked this conversation as resolved.

return await McpClientFactory.CreateAsync(clientTransport, clientOptions, loggerFactory: this._loggerFactory);
}

return await McpClientFactory.CreateAsync(clientTransport, loggerFactory: this._loggerFactory);
}
catch (Exception ex)
Expand All @@ -536,6 +557,35 @@ private async Task<IMcpClient> CreateMcpClientWithAuthHandlers(ITurnContext turn
}
}

/// <summary>
/// Validates <see cref="ToolOptions.McpClientInitializationTimeoutSeconds"/> and converts it
/// to a <see cref="TimeSpan"/>. Returns <c>null</c> when no custom timeout is configured so
/// callers can preserve the MCP SDK default behavior.
/// </summary>
/// <param name="timeoutSeconds">The configured timeout in seconds, or <c>null</c> to use the SDK default.</param>
/// <returns>The validated timeout as a <see cref="TimeSpan"/>, or <c>null</c> when not configured.</returns>
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown when <paramref name="timeoutSeconds"/> is set but falls outside the supported range of 1 to 600 seconds.
/// </exception>
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);
}

/// <summary>
/// Attaches a per-audience Bearer token to each server's
/// <see cref="MCPServerConfig.Headers"/> dictionary.
Expand Down
Loading