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);
+ }
}