diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/Program.cs index ec70fe3b51..4327562ee2 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/Program.cs @@ -108,6 +108,6 @@ internal sealed class DevTemporaryTokenCredential : TokenCredential throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set."); } - return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1)); + return new AccessToken(this._token, DateTimeOffset.MaxValue); } } diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs index d5f1c9a88d..e4661f3217 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs @@ -8,6 +8,7 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using OpenAI; using OpenAI.Chat; +using AgentCard = A2A.AgentCard; namespace A2AServer; diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/HostedMcpToolboxAITool.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/HostedMcpToolboxAITool.cs index 79d0755e87..6af3ab2ff0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/HostedMcpToolboxAITool.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/HostedMcpToolboxAITool.cs @@ -34,7 +34,11 @@ public sealed class HostedMcpToolboxAITool : HostedMcpServerTool /// Initializes a new instance of the class. /// /// The Foundry toolbox name. - /// Optional pinned toolbox version. When , the project's default version is used. + /// + /// Optional pinned toolbox version. When , the project's default version is used. + /// Currently reserved for forward compatibility — version-specific routing is handled server-side by + /// the Foundry proxy. + /// public HostedMcpToolboxAITool(string toolboxName, string? version = null) : base( serverName: NotNullOrWhitespace(toolboxName, nameof(toolboxName)), @@ -63,7 +67,7 @@ public sealed class HostedMcpToolboxAITool : HostedMcpServerTool return string.IsNullOrEmpty(version) ? $"{UriScheme}://{toolboxName}" - : $"{UriScheme}://{toolboxName}?version={version}"; + : $"{UriScheme}://{toolboxName}?version={Uri.EscapeDataString(version)}"; } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxBearerTokenHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxBearerTokenHandler.cs index ac873b1e84..d345297276 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxBearerTokenHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxBearerTokenHandler.cs @@ -48,6 +48,7 @@ internal sealed class FoundryToolboxBearerTokenHandler : DelegatingHandler request.Headers.TryAddWithoutValidation("Foundry-Features", this._featuresHeaderValue); } + // MaxRetries is the total number of attempts (not additional retries after the first). for (int attempt = 0; attempt < MaxRetries; attempt++) { // Clone the request for retries (the original request cannot be sent twice) @@ -65,19 +66,20 @@ internal sealed class FoundryToolboxBearerTokenHandler : DelegatingHandler return response; } + // Last attempt exhausted — return the error response as-is. + if (attempt == MaxRetries - 1) + { + return response; + } + response.Dispose(); - if (attempt < MaxRetries - 1) - { - await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken) - .ConfigureAwait(false); - } + await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken) + .ConfigureAwait(false); } - // Final attempt after backoff exhausted — return last response (already disposed above, so resend) - return await base.SendAsync( - await CloneRequestAsync(request, cancellationToken).ConfigureAwait(false), - cancellationToken).ConfigureAwait(false); + // Unreachable when MaxRetries > 0, but satisfies the compiler. + throw new InvalidOperationException("Retry loop completed without returning a response."); } private static async Task CloneRequestAsync( diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxService.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxService.cs index 315426574f..8ebde63879 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxService.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxService.cs @@ -127,7 +127,11 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable /// to either reject or lazily open it. /// /// The Foundry toolbox name from the marker. - /// Optional pinned version; ignored when matching a pre-registered entry. + /// + /// Optional pinned version. Currently reserved for future use — version-specific routing is + /// handled server-side by the Foundry proxy. This parameter is accepted for forward compatibility + /// but does not affect the proxy URL used to connect to the toolbox. + /// /// The request cancellation token. /// /// Thrown when the toolbox is not pre-registered and diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs index 0e6614d7c7..4192599bd0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs @@ -161,7 +161,8 @@ public static class FoundryHostingExtensions // Register FoundryToolboxService as a singleton so it can be injected into the handler services.TryAddSingleton(); - // Add it as a hosted service so StartAsync is called before the app starts serving requests + // AddHostedService uses TryAddEnumerable internally, so calling AddFoundryToolboxes + // multiple times will not invoke StartAsync twice on the same singleton. services.AddHostedService(sp => sp.GetRequiredService()); return services; diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryToolboxBearerTokenHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryToolboxBearerTokenHandlerTests.cs new file mode 100644 index 0000000000..cea48d8eb0 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryToolboxBearerTokenHandlerTests.cs @@ -0,0 +1,185 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Microsoft.Agents.AI.Foundry.Hosting; +using Moq; + +namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; + +public class FoundryToolboxBearerTokenHandlerTests +{ + private const string FakeToken = "test-bearer-token"; + + private static Mock CreateMockCredential() + { + var mock = new Mock(); + mock.Setup(c => c.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new AccessToken(FakeToken, DateTimeOffset.UtcNow.AddHours(1))); + return mock; + } + + private static (FoundryToolboxBearerTokenHandler Handler, CountingHandler Inner) CreateHandlerPair( + Mock? credential = null, + string? featuresHeader = null, + HttpStatusCode statusCode = HttpStatusCode.OK) + { + credential ??= CreateMockCredential(); + var inner = new CountingHandler(statusCode); + var handler = new FoundryToolboxBearerTokenHandler(credential.Object, featuresHeader) + { + InnerHandler = inner + }; + return (handler, inner); + } + + [Fact] + public async Task SendAsync_InjectsBearerTokenAsync() + { + var (handler, _) = CreateHandlerPair(); + using var invoker = new HttpMessageInvoker(handler); + + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api"); + using var response = await invoker.SendAsync(request, CancellationToken.None); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Bearer", request.Headers.Authorization?.Scheme); + Assert.Equal(FakeToken, request.Headers.Authorization?.Parameter); + } + + [Fact] + public async Task SendAsync_InjectsFoundryFeaturesHeaderAsync() + { + var (handler, _) = CreateHandlerPair(featuresHeader: "feature1,feature2"); + using var invoker = new HttpMessageInvoker(handler); + + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api"); + using var response = await invoker.SendAsync(request, CancellationToken.None); + + Assert.True(request.Headers.TryGetValues("Foundry-Features", out var values)); + Assert.Contains("feature1,feature2", values); + } + + [Fact] + public async Task SendAsync_OmitsFeaturesHeaderWhenNullAsync() + { + var (handler, _) = CreateHandlerPair(featuresHeader: null); + using var invoker = new HttpMessageInvoker(handler); + + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api"); + using var response = await invoker.SendAsync(request, CancellationToken.None); + + Assert.False(request.Headers.Contains("Foundry-Features")); + } + + [Theory] + [InlineData(HttpStatusCode.OK)] + [InlineData(HttpStatusCode.Created)] + [InlineData(HttpStatusCode.BadRequest)] + [InlineData(HttpStatusCode.NotFound)] + public async Task SendAsync_NonRetryableStatusCode_ReturnsImmediatelyAsync(HttpStatusCode statusCode) + { + var (handler, inner) = CreateHandlerPair(statusCode: statusCode); + using var invoker = new HttpMessageInvoker(handler); + + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api"); + using var response = await invoker.SendAsync(request, CancellationToken.None); + + Assert.Equal(statusCode, response.StatusCode); + Assert.Equal(1, inner.CallCount); + } + + [Theory] + [InlineData(HttpStatusCode.TooManyRequests)] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.BadGateway)] + [InlineData(HttpStatusCode.ServiceUnavailable)] + public async Task SendAsync_RetryableStatusCode_RetriesMaxTimesAsync(HttpStatusCode statusCode) + { + var (handler, inner) = CreateHandlerPair(statusCode: statusCode); + using var invoker = new HttpMessageInvoker(handler); + + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api"); + using var response = await invoker.SendAsync(request, CancellationToken.None); + + // MaxRetries is 3, so exactly 3 total attempts (not 4). + Assert.Equal(3, inner.CallCount); + Assert.Equal(statusCode, response.StatusCode); + } + + [Fact] + public async Task SendAsync_RetryableStatusCode_SucceedsOnSecondAttemptAsync() + { + // First call returns 503, second returns 200. + var inner = new SequenceHandler( + HttpStatusCode.ServiceUnavailable, + HttpStatusCode.OK); + + var handler = new FoundryToolboxBearerTokenHandler(CreateMockCredential().Object, null) + { + InnerHandler = inner + }; + using var invoker = new HttpMessageInvoker(handler); + + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api"); + using var response = await invoker.SendAsync(request, CancellationToken.None); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(2, inner.CallCount); + } + + /// + /// A test handler that always returns the configured status code and counts how many times it was called. + /// + private sealed class CountingHandler : HttpMessageHandler + { + private readonly HttpStatusCode _statusCode; + private int _callCount; + + public int CallCount => this._callCount; + + public CountingHandler(HttpStatusCode statusCode) + { + this._statusCode = statusCode; + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Interlocked.Increment(ref this._callCount); + return Task.FromResult(new HttpResponseMessage(this._statusCode)); + } + } + + /// + /// A test handler that returns status codes from a sequence, cycling through them. + /// + private sealed class SequenceHandler : HttpMessageHandler + { + private readonly HttpStatusCode[] _statusCodes; + private int _callCount; + + public int CallCount => this._callCount; + + public SequenceHandler(params HttpStatusCode[] statusCodes) + { + this._statusCodes = statusCodes; + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var index = Interlocked.Increment(ref this._callCount) - 1; + var statusCode = index < this._statusCodes.Length + ? this._statusCodes[index] + : this._statusCodes[^1]; + return Task.FromResult(new HttpResponseMessage(statusCode)); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/InputConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/InputConverterTests.cs index 491b1400e5..e2f6159a6e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/InputConverterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/InputConverterTests.cs @@ -668,4 +668,100 @@ public class InputConverterTests // Model from the request is intentionally NOT propagated — the hosted agent uses its own model. Assert.Null(options.ModelId); } + + // ── ReadMcpToolboxMarkers tests ────────────────────────────────────────────── + + [Fact] + public void ReadMcpToolboxMarkers_NullTools_ReturnsEmpty() + { + var request = new CreateResponse(); + // Tools defaults to null when not set via JSON deserialization. + + var markers = InputConverter.ReadMcpToolboxMarkers(request); + + Assert.Empty(markers); + } + + [Fact] + public void ReadMcpToolboxMarkers_McpToolWithToolboxAddress_ReturnsMarker() + { + var request = new CreateResponse(); + request.Tools.Add(new MCPTool("test-toolbox") + { + ServerUrl = new Uri("foundry-toolbox://my-toolbox") + }); + + var markers = InputConverter.ReadMcpToolboxMarkers(request); + + Assert.Single(markers); + Assert.Equal("my-toolbox", markers[0].Name); + Assert.Null(markers[0].Version); + } + + [Fact] + public void ReadMcpToolboxMarkers_McpToolWithVersionedAddress_ReturnsNameAndVersion() + { + var request = new CreateResponse(); + request.Tools.Add(new MCPTool("test-toolbox") + { + ServerUrl = new Uri("foundry-toolbox://my-toolbox?version=v3") + }); + + var markers = InputConverter.ReadMcpToolboxMarkers(request); + + Assert.Single(markers); + Assert.Equal("my-toolbox", markers[0].Name); + Assert.Equal("v3", markers[0].Version); + } + + [Fact] + public void ReadMcpToolboxMarkers_McpToolWithNonToolboxUrl_SkipsIt() + { + var request = new CreateResponse(); + request.Tools.Add(new MCPTool("external-mcp") + { + ServerUrl = new Uri("https://example.com/mcp") + }); + + var markers = InputConverter.ReadMcpToolboxMarkers(request); + + Assert.Empty(markers); + } + + [Fact] + public void ReadMcpToolboxMarkers_McpToolWithNullServerUrl_SkipsIt() + { + var request = new CreateResponse(); + request.Tools.Add(new MCPTool("test") { ServerUrl = null }); + + var markers = InputConverter.ReadMcpToolboxMarkers(request); + + Assert.Empty(markers); + } + + [Fact] + public void ReadMcpToolboxMarkers_MixedTools_ReturnsOnlyToolboxMarkers() + { + var request = new CreateResponse(); + request.Tools.Add(new MCPTool("external") + { + ServerUrl = new Uri("https://example.com/mcp") + }); + request.Tools.Add(new MCPTool("toolbox-1") + { + ServerUrl = new Uri("foundry-toolbox://box-a") + }); + request.Tools.Add(new MCPTool("toolbox-2") + { + ServerUrl = new Uri("foundry-toolbox://box-b?version=2025-01") + }); + + var markers = InputConverter.ReadMcpToolboxMarkers(request); + + Assert.Equal(2, markers.Count); + Assert.Equal("box-a", markers[0].Name); + Assert.Null(markers[0].Version); + Assert.Equal("box-b", markers[1].Name); + Assert.Equal("2025-01", markers[1].Version); + } }