Files
agent-framework/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryToolboxBearerTokenHandlerTests.cs
Ben Thomas d30cd927b0 .NET: Hosted agents toolbox support (#5368)
* feat: Add Foundry Toolbox (MCP) support to AgentFrameworkResponseHandler

Adds support for Foundry Toolsets MCP proxy integration in the hosted agent
response handler. Toolsets connect at startup via IHostedService, gating the
readiness probe per spec §3.1. MCP tools are injected into every request's
ChatOptions and OAuth consent errors (-32006) are intercepted and surfaced as
mcp_approval_request + incomplete SSE events.

New files:
- FoundryToolboxOptions.cs: configuration POCO for toolset names and API version
- FoundryToolboxBearerTokenHandler.cs: DelegatingHandler with Azure Bearer token
  auth, Foundry-Features header injection, and 3x exponential backoff on 429/5xx
- McpConsentContext.cs: AsyncLocal-based per-request consent state shared between
  the tool wrapper and the response handler
- ConsentAwareMcpClientTool.cs: AIFunction wrapper that catches -32006 errors and
  signals consent via shared state and linked CancellationTokenSource
- FoundryToolboxService.cs: IHostedService that creates McpClient per toolset at
  startup and exposes cached tools

Modified files:
- AgentFrameworkResponseHandler.cs: injects toolbox tools into ChatOptions, sets
  up linked CTS consent interception, emits mcp_approval_request on -32006
- ServiceCollectionExtensions.cs: adds AddFoundryToolboxes(params string[]) extension
- Microsoft.Agents.AI.Foundry.csproj: adds ModelContextProtocol and Azure.Identity
  dependencies under NETCoreApp condition

Sample:
- Hosted-Toolbox: minimal hosted agent sample using AddFoundryToolboxes

* Rename toolset to toolbox in user-facing API; rename ConsentAwareMcpClientTool to ConsentAwareMcpClientAIFunction

* Add HostedMcpToolboxAITool for client-selectable Foundry toolboxes

Introduces HostedMcpToolboxAITool, a marker tool subclassing HostedMcpServerTool that rides the OpenAI Responses 'mcp' wire format to let clients request a specific Foundry toolbox per request.

- New FoundryAITool.CreateHostedMcpToolbox(name, version?) factory.

- FoundryToolboxOptions.StrictMode (default true) rejects unregistered toolboxes; set to false to allow lazy-open on first use.

- FoundryToolboxService.GetToolboxToolsAsync(name, version?) resolves cached or lazy-opened MCP tools.

- AgentFrameworkResponseHandler parses request.Tools for foundry-toolbox://name[?version=v] markers and injects resolved tools per request, merging with pre-registered ones.

- Unit tests for marker parsing and strict-mode resolution.

* Bump Azure.AI.Projects to 2.1.0-alpha; add ToolboxRecord/ToolboxVersion factory overloads + tests

* Fix PR review issues: retry off-by-one, URI encoding, docs, tests, build

- Fix off-by-one in FoundryToolboxBearerTokenHandler retry loop (4 attempts → 3)
- URI-encode version parameter in HostedMcpToolboxAITool.BuildAddress
- Add XML doc clarifying version pinning is reserved for future use
- Add comment clarifying AddHostedService deduplication safety
- Fix DevTemporaryTokenCredential expiry to use DateTimeOffset.MaxValue
- Fix AgentCard ambiguity in A2AServer sample with using alias
- Add 18 new unit tests for retry handler and ReadMcpToolboxMarkers

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-20 12:13:40 -07:00

186 lines
6.8 KiB
C#

// 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<TokenCredential> CreateMockCredential()
{
var mock = new Mock<TokenCredential>();
mock.Setup(c => c.GetTokenAsync(It.IsAny<TokenRequestContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AccessToken(FakeToken, DateTimeOffset.UtcNow.AddHours(1)));
return mock;
}
private static (FoundryToolboxBearerTokenHandler Handler, CountingHandler Inner) CreateHandlerPair(
Mock<TokenCredential>? 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);
}
/// <summary>
/// A test handler that always returns the configured status code and counts how many times it was called.
/// </summary>
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<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Interlocked.Increment(ref this._callCount);
return Task.FromResult(new HttpResponseMessage(this._statusCode));
}
}
/// <summary>
/// A test handler that returns status codes from a sequence, cycling through them.
/// </summary>
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<HttpResponseMessage> 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));
}
}
}