.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>
This commit is contained in:
Ben Thomas
2026-04-20 12:13:40 -07:00
committed by GitHub
Unverified
parent 3c48ca17c8
commit d30cd927b0
21 changed files with 1609 additions and 4 deletions
+1 -1
View File
@@ -22,7 +22,7 @@
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.22" />
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.1" />
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.3" />
<PackageVersion Include="Azure.AI.Projects" Version="2.0.0" />
<PackageVersion Include="Azure.AI.Projects" Version="2.1.0-alpha.20260417.1" />
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.10" />
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
<PackageVersion Include="Azure.Core" Version="1.53.0" />
+3
View File
@@ -283,6 +283,9 @@
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj" />
</Folder>
+6
View File
@@ -3,10 +3,16 @@
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
<add key="azure-sdk-dev" value="https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-net/nuget/v3/index.json" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
<packageSource key="azure-sdk-dev">
<package pattern="Azure.AI.Projects*" />
<package pattern="Azure.AI.AgentServer*" />
<package pattern="Azure.AI.Extensions.OpenAI*" />
</packageSource>
</packageSourceMapping>
</configuration>
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
<RootNamespace>HostedToolbox</RootNamespace>
<AssemblyName>HostedToolbox</AssemblyName>
<NoWarn>$(NoWarn);</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
<!-- For contributors: uses ProjectReference to build against local source -->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
</ItemGroup>
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
</ItemGroup>
-->
</Project>
@@ -0,0 +1,113 @@
// Copyright (c) Microsoft. All rights reserved.
// Foundry Toolbox Agent - A hosted agent that uses Foundry Toolset MCP tools.
//
// Demonstrates how to register one or more Foundry toolsets so the agent can
// call tools provided by the Foundry platform's managed MCP proxy.
//
// Required environment variables:
// AZURE_AI_PROJECT_ENDPOINT - Azure AI Foundry project endpoint
// AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-4o)
// FOUNDRY_AGENT_TOOLSET_ENDPOINT - Foundry Toolsets proxy base URL
// (injected automatically by Foundry platform at runtime)
//
// Optional:
// FOUNDRY_TOOLBOX_NAME - Name of the toolset to load (default: my-toolset)
// FOUNDRY_AGENT_NAME - Client name reported to MCP server
// FOUNDRY_AGENT_VERSION - Client version reported to MCP server
// FOUNDRY_AGENT_TOOLSET_FEATURES - Feature flags sent to Foundry proxy via header
using Azure.AI.Projects;
using Azure.Core;
using Azure.Identity;
using DotNetEnv;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
// Load .env file if present (for local development)
Env.TraversePath().Load();
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
string toolboxName = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_NAME") ?? "my-toolset";
// Use a chained credential: try a temporary dev token first (for local Docker debugging),
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production).
TokenCredential credential = new ChainedTokenCredential(
new DevTemporaryTokenCredential(),
new DefaultAzureCredential());
// ── Create agent ─────────────────────────────────────────────────────────────
AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
.AsAIAgent(
model: deploymentName,
instructions: """
You are a helpful assistant with access to tools provided by the Foundry Toolset.
Use the available tools to answer user questions.
If a tool is not available for a request, let the user know clearly.
""",
name: Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-toolbox-agent",
description: "Hosted agent backed by Foundry Toolset MCP tools");
// ── Build the host ────────────────────────────────────────────────────────────
var builder = WebApplication.CreateBuilder(args);
// Register the agent and response handler
builder.Services.AddFoundryResponses(agent);
// Register Foundry Toolbox: connects to the MCP proxy at startup and makes tools available.
// The toolset name must match a toolset registered in your Foundry project.
// When FOUNDRY_AGENT_TOOLSET_ENDPOINT is absent (e.g., in local development without Foundry
// infrastructure), startup succeeds without error and no toolbox tools are loaded.
builder.Services.AddFoundryToolboxes(toolboxName);
var app = builder.Build();
app.MapFoundryResponses();
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
app.Run();
// ── DevTemporaryTokenCredential ───────────────────────────────────────────────
/// <summary>
/// A <see cref="TokenCredential"/> for local Docker debugging only.
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable
/// once at startup. This should NOT be used in production.
///
/// Generate a token on your host and pass it to the container:
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
/// </summary>
internal sealed class DevTemporaryTokenCredential : TokenCredential
{
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
private readonly string? _token;
public DevTemporaryTokenCredential()
{
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
}
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
=> this.GetAccessToken();
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
=> new(this.GetAccessToken());
private AccessToken GetAccessToken()
{
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
{
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
}
return new AccessToken(this._token, DateTimeOffset.MaxValue);
}
}
@@ -8,6 +8,7 @@ using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Chat;
using AgentCard = A2A.AgentCard;
namespace A2AServer;
@@ -112,6 +112,50 @@ public static class FoundryAITool
public static AITool CreateA2ATool(Uri baseUri, string? agentCardPath = null)
=> ProjectsAgentTool.CreateA2ATool(baseUri, agentCardPath).AsAITool();
/// <summary>
/// Creates an <see cref="AITool"/> marker that references a Foundry Toolbox by name so
/// the hosted server side can resolve and expose its MCP tools for a single request.
/// </summary>
/// <param name="toolboxName">The Foundry toolbox name.</param>
/// <param name="version">Optional pinned toolbox version. When <see langword="null"/>, the project's default version is used.</param>
/// <returns>An <see cref="AITool"/> marker backed by <see cref="HostedMcpToolboxAITool"/>.</returns>
public static AITool CreateHostedMcpToolbox(string toolboxName, string? version = null)
=> new HostedMcpToolboxAITool(toolboxName, version);
/// <summary>
/// Creates an <see cref="AITool"/> marker from a <see cref="ToolboxRecord"/> retrieved
/// from <c>AIProjectClient</c>. Uses <see cref="ToolboxRecord.Name"/> and
/// <see cref="ToolboxRecord.DefaultVersion"/>.
/// </summary>
/// <param name="toolbox">The toolbox record.</param>
/// <returns>An <see cref="AITool"/> marker backed by <see cref="HostedMcpToolboxAITool"/>.</returns>
public static AITool CreateHostedMcpToolbox(ToolboxRecord toolbox)
{
if (toolbox is null)
{
throw new ArgumentNullException(nameof(toolbox));
}
return new HostedMcpToolboxAITool(toolbox.Name, toolbox.DefaultVersion);
}
/// <summary>
/// Creates an <see cref="AITool"/> marker from a specific <see cref="ToolboxVersion"/>
/// retrieved from <c>AIProjectClient</c>. Uses <see cref="ToolboxVersion.Name"/> and
/// <see cref="ToolboxVersion.Version"/>.
/// </summary>
/// <param name="toolboxVersion">The toolbox version.</param>
/// <returns>An <see cref="AITool"/> marker backed by <see cref="HostedMcpToolboxAITool"/>.</returns>
public static AITool CreateHostedMcpToolbox(ToolboxVersion toolboxVersion)
{
if (toolboxVersion is null)
{
throw new ArgumentNullException(nameof(toolboxVersion));
}
return new HostedMcpToolboxAITool(toolboxVersion.Name, toolboxVersion.Version);
}
// --- OpenAI SDK ResponseTool factories ---
/// <summary>
@@ -0,0 +1,156 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry;
/// <summary>
/// A marker <see cref="HostedMcpServerTool"/> that identifies a Foundry Toolbox by name
/// (and optional version) on the OpenAI Responses <c>mcp</c> wire format.
/// </summary>
/// <remarks>
/// <para>
/// The hosted server recognizes this marker by its <see cref="HostedMcpServerTool.ServerAddress"/>
/// scheme (<see cref="UriScheme"/>) and resolves it to the set of MCP tools exposed by the
/// matching toolbox registered in the Foundry project.
/// </para>
/// <para>
/// Callers should not construct this type directly. Use one of the
/// <c>FoundryAITool.CreateHostedMcpToolbox(...)</c> factory overloads.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public sealed class HostedMcpToolboxAITool : HostedMcpServerTool
{
/// <summary>
/// The URI scheme used to identify Foundry Toolbox markers on the wire.
/// </summary>
public const string UriScheme = "foundry-toolbox";
/// <summary>
/// Initializes a new instance of the <see cref="HostedMcpToolboxAITool"/> class.
/// </summary>
/// <param name="toolboxName">The Foundry toolbox name.</param>
/// <param name="version">
/// Optional pinned toolbox version. When <see langword="null"/>, the project's default version is used.
/// Currently reserved for forward compatibility — version-specific routing is handled server-side by
/// the Foundry proxy.
/// </param>
public HostedMcpToolboxAITool(string toolboxName, string? version = null)
: base(
serverName: NotNullOrWhitespace(toolboxName, nameof(toolboxName)),
serverAddress: BuildAddress(toolboxName, version))
{
this.ToolboxName = toolboxName;
this.Version = version;
}
/// <summary>
/// Gets the Foundry toolbox name.
/// </summary>
public string ToolboxName { get; }
/// <summary>
/// Gets the pinned toolbox version, or <see langword="null"/> to use the project's default.
/// </summary>
public string? Version { get; }
/// <summary>
/// Builds the toolbox marker address: <c>foundry-toolbox://{name}[?version={v}]</c>.
/// </summary>
public static string BuildAddress(string toolboxName, string? version)
{
_ = NotNullOrWhitespace(toolboxName, nameof(toolboxName));
return string.IsNullOrEmpty(version)
? $"{UriScheme}://{toolboxName}"
: $"{UriScheme}://{toolboxName}?version={Uri.EscapeDataString(version)}";
}
/// <summary>
/// Attempts to parse a toolbox marker address into its name and optional version components.
/// </summary>
/// <param name="address">The <see cref="HostedMcpServerTool.ServerAddress"/> to inspect.</param>
/// <param name="toolboxName">When this method returns <see langword="true"/>, the parsed toolbox name.</param>
/// <param name="version">When this method returns <see langword="true"/>, the optional version, or <see langword="null"/>.</param>
/// <returns><see langword="true"/> if <paramref name="address"/> is a Foundry toolbox marker; otherwise <see langword="false"/>.</returns>
public static bool TryParseToolboxAddress(
string? address,
[NotNullWhen(true)] out string? toolboxName,
out string? version)
{
toolboxName = null;
version = null;
if (string.IsNullOrEmpty(address))
{
return false;
}
if (!Uri.TryCreate(address, UriKind.Absolute, out var uri))
{
return false;
}
if (!string.Equals(uri.Scheme, UriScheme, StringComparison.OrdinalIgnoreCase))
{
return false;
}
// For foundry-toolbox://name, the name appears as Authority (host) with an empty path.
// For foundry-toolbox:name (rare), it falls through to PathAndQuery.
var name = uri.Host;
if (string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(uri.AbsolutePath))
{
name = uri.AbsolutePath.TrimStart('/');
}
if (string.IsNullOrEmpty(name))
{
return false;
}
toolboxName = name;
var query = uri.Query;
if (!string.IsNullOrEmpty(query))
{
// Minimal parser to avoid a HttpUtility dependency on netstandard.
foreach (var part in query.TrimStart('?').Split('&'))
{
var eq = part.IndexOf('=');
if (eq <= 0)
{
continue;
}
var key = part.Substring(0, eq);
if (string.Equals(key, "version", StringComparison.OrdinalIgnoreCase))
{
version = Uri.UnescapeDataString(part.Substring(eq + 1));
break;
}
}
}
return true;
}
private static string NotNullOrWhitespace(string value, string paramName)
{
if (value is null)
{
throw new ArgumentNullException(paramName);
}
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Value cannot be empty or whitespace.", paramName);
}
return value;
}
}
@@ -21,6 +21,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<AgentFrameworkResponseHandler> _logger;
private readonly FoundryToolboxService? _toolboxService;
/// <summary>
/// Initializes a new instance of the <see cref="AgentFrameworkResponseHandler"/> class
@@ -28,15 +29,18 @@ public class AgentFrameworkResponseHandler : ResponseHandler
/// </summary>
/// <param name="serviceProvider">The service provider for resolving agents.</param>
/// <param name="logger">The logger instance.</param>
/// <param name="toolboxService">Optional Foundry Toolbox service providing MCP tools.</param>
public AgentFrameworkResponseHandler(
IServiceProvider serviceProvider,
ILogger<AgentFrameworkResponseHandler> logger)
ILogger<AgentFrameworkResponseHandler> logger,
FoundryToolboxService? toolboxService = null)
{
ArgumentNullException.ThrowIfNull(serviceProvider);
ArgumentNullException.ThrowIfNull(logger);
this._serviceProvider = serviceProvider;
this._logger = logger;
this._toolboxService = toolboxService;
}
/// <inheritdoc/>
@@ -92,14 +96,97 @@ public class AgentFrameworkResponseHandler : ResponseHandler
// 5. Build chat options
var chatOptions = InputConverter.ConvertToChatOptions(request);
chatOptions.Instructions = request.Instructions;
// Inject Foundry Toolbox tools when the toolbox service is available.
//
// Two sources are considered:
// 1. Pre-registered toolboxes (via AddFoundryToolboxes) — always appended.
// 2. Per-request markers embedded in request.Tools (HostedMcpToolboxAITool)
// whose ServerAddress scheme is "foundry-toolbox://". Strict mode rejects
// unknown names; otherwise a lazy MCP client is opened and cached.
//
// Each toolbox's tools are only appended once per request, even if it appears
// in both the pre-registered list and the per-request markers.
if (this._toolboxService is not null)
{
List<AITool>? toolsToAdd = null;
if (this._toolboxService.Tools.Count > 0)
{
toolsToAdd = [.. this._toolboxService.Tools];
}
var markers = InputConverter.ReadMcpToolboxMarkers(request);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
string? resolutionError = null;
foreach (var (name, version) in markers)
{
if (!seen.Add(name))
{
continue;
}
IReadOnlyList<AITool>? toolboxTools = null;
try
{
toolboxTools = await this._toolboxService
.GetToolboxToolsAsync(name, version, cancellationToken)
.ConfigureAwait(false);
}
catch (InvalidOperationException ex)
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
this._logger.LogWarning(
ex,
"Foundry toolbox '{ToolboxName}' could not be resolved for response {ResponseId}.",
name,
context.ResponseId);
}
resolutionError = ex.Message;
break;
}
toolsToAdd ??= [];
foreach (var t in toolboxTools)
{
if (!toolsToAdd.Contains(t))
{
toolsToAdd.Add(t);
}
}
}
if (resolutionError is not null)
{
yield return stream.EmitFailed(ResponseErrorCode.ServerError, resolutionError);
yield break;
}
if (toolsToAdd?.Count > 0)
{
chatOptions.Tools = [.. chatOptions.Tools ?? [], .. toolsToAdd];
}
}
var options = new ChatClientAgentRunOptions(chatOptions);
// 6. Run the agent and convert output
// 6. Set up consent context for -32006 OAuth consent interception.
// We create a linked CTS so the consent-aware tool wrapper can cancel the agent
// run mid-loop when a -32006 error is returned by the proxy. The RequestConsentState
// is a shared mutable object that flows via AsyncLocal to the tool wrapper.
using var consentCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var consentState = new RequestConsentState { CancellationSource = consentCts };
McpConsentContext.Current.Value = consentState;
// 7. Run the agent and convert output
// NOTE: C# forbids 'yield return' inside a try block that has a catch clause,
// and inside catch blocks. We use a flag to defer the yield to outside the try/catch.
bool emittedTerminal = false;
var enumerator = OutputConverter.ConvertUpdatesToEventsAsync(
agent.RunStreamingAsync(messages, session, options: options, cancellationToken: cancellationToken),
agent.RunStreamingAsync(messages, session, options: options, cancellationToken: consentCts.Token),
stream,
cancellationToken).GetAsyncEnumerator(cancellationToken);
try
@@ -107,6 +194,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler
while (true)
{
bool shutdownDetected = false;
McpConsentInfo? consentInfo = null;
ResponseStreamEvent? failedEvent = null;
ResponseStreamEvent? evt = null;
try
@@ -118,6 +206,11 @@ public class AgentFrameworkResponseHandler : ResponseHandler
evt = enumerator.Current;
}
catch (OperationCanceledException) when (!emittedTerminal && consentState.Pending is not null)
{
// -32006 consent error: the tool wrapper cancelled consentCts and stored consent info.
consentInfo = consentState.Pending;
}
catch (OperationCanceledException) when (context.IsShutdownRequested && !emittedTerminal)
{
shutdownDetected = true;
@@ -137,6 +230,21 @@ public class AgentFrameworkResponseHandler : ResponseHandler
ex.Message);
}
if (consentInfo is not null)
{
// Emit mcp_approval_request output item + incomplete for the consent URL.
foreach (var approvalEvent in stream.OutputItemMcpApprovalRequest(
consentInfo.ToolboxName,
consentInfo.ToolName,
consentInfo.ConsentUrl))
{
yield return approvalEvent;
}
yield return stream.EmitIncomplete(reason: null);
yield break;
}
if (failedEvent is not null)
{
yield return failedEvent;
@@ -0,0 +1,70 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using ModelContextProtocol;
using ModelContextProtocol.Client;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// An <see cref="AIFunction"/> wrapper around <see cref="McpClientTool"/> that intercepts
/// JSON-RPC error -32006 (OAuth consent required) from the Foundry Toolsets proxy and
/// propagates it back to <see cref="AgentFrameworkResponseHandler"/> via
/// <see cref="McpConsentContext"/>.
/// </summary>
/// <remarks>
/// <para>
/// When the proxy returns -32006, the consent URL is stored in <see cref="McpConsentContext.Current"/>
/// and the per-request <see cref="RequestConsentState.CancellationSource"/> is cancelled. This causes
/// <see cref="FunctionInvokingChatClient"/> to stop the tool loop (it guards
/// exceptions with <c>when (!ct.IsCancellationRequested)</c>) and surfaces an
/// <see cref="System.OperationCanceledException"/> to the handler. The handler then emits the
/// <c>mcp_approval_request</c> output item and marks the response as <c>incomplete</c>.
/// </para>
/// </remarks>
internal sealed class ConsentAwareMcpClientAIFunction : AIFunction
{
private readonly McpClientTool _inner;
private readonly string _toolboxName;
internal ConsentAwareMcpClientAIFunction(McpClientTool inner, string toolboxName)
{
this._inner = inner;
this._toolboxName = toolboxName;
}
public override string Name => this._inner.Name;
public override string Description => this._inner.Description;
public override JsonElement JsonSchema => this._inner.JsonSchema;
public override JsonElement? ReturnJsonSchema => this._inner.ReturnJsonSchema;
public override JsonSerializerOptions JsonSerializerOptions => this._inner.JsonSerializerOptions;
protected override async ValueTask<object?> InvokeCoreAsync(
AIFunctionArguments arguments,
CancellationToken cancellationToken)
{
try
{
return await this._inner.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false);
}
catch (McpProtocolException ex) when ((int)ex.ErrorCode == -32006)
{
var state = McpConsentContext.Current.Value;
if (state is not null)
{
state.Pending = new McpConsentInfo(this._toolboxName, this._inner.Name, ex.Message);
state.CancellationSource?.Cancel();
}
cancellationToken.ThrowIfCancellationRequested();
throw; // fallback if the CT wasn't cancelled for some reason
}
}
}
@@ -0,0 +1,109 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Azure.Core;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// An <see cref="DelegatingHandler"/> that:
/// <list type="bullet">
/// <item>Acquires a fresh Azure bearer token (scope: <c>https://cognitiveservices.azure.com/.default</c>) per request.</item>
/// <item>Injects the <c>Foundry-Features</c> header from <c>FOUNDRY_AGENT_TOOLSET_FEATURES</c> when non-empty.</item>
/// <item>Retries on HTTP 429, 500, 502, and 503 with exponential back-off (max 3 attempts, per spec §7).</item>
/// </list>
/// </summary>
internal sealed class FoundryToolboxBearerTokenHandler : DelegatingHandler
{
private const int MaxRetries = 3;
private static readonly TokenRequestContext s_tokenContext =
new(["https://cognitiveservices.azure.com/.default"]);
private readonly TokenCredential _credential;
private readonly string? _featuresHeaderValue;
internal FoundryToolboxBearerTokenHandler(TokenCredential credential, string? featuresHeaderValue)
{
this._credential = credential;
this._featuresHeaderValue = featuresHeaderValue;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var token = await this._credential
.GetTokenAsync(s_tokenContext, cancellationToken)
.ConfigureAwait(false);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
if (!string.IsNullOrEmpty(this._featuresHeaderValue))
{
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)
HttpRequestMessage requestToSend = attempt == 0
? request
: await CloneRequestAsync(request, cancellationToken).ConfigureAwait(false);
var response = await base.SendAsync(requestToSend, cancellationToken).ConfigureAwait(false);
if (response.StatusCode is not (HttpStatusCode.TooManyRequests
or HttpStatusCode.InternalServerError
or HttpStatusCode.BadGateway
or HttpStatusCode.ServiceUnavailable))
{
return response;
}
// Last attempt exhausted — return the error response as-is.
if (attempt == MaxRetries - 1)
{
return response;
}
response.Dispose();
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), 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<HttpRequestMessage> CloneRequestAsync(
HttpRequestMessage original,
CancellationToken cancellationToken)
{
var clone = new HttpRequestMessage(original.Method, original.RequestUri);
foreach (var header in original.Headers)
{
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
if (original.Content is not null)
{
var contentBytes = await original.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
clone.Content = new ByteArrayContent(contentBytes);
foreach (var header in original.Content.Headers)
{
clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
}
return clone;
}
}
@@ -0,0 +1,40 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Options for Foundry Toolbox MCP integration.
/// </summary>
public sealed class FoundryToolboxOptions
{
/// <summary>
/// Gets the list of toolbox names to connect to at startup.
/// Each name corresponds to a toolbox registered in the Foundry project.
/// The platform proxy URL is constructed as:
/// <c>{FOUNDRY_AGENT_TOOLSET_ENDPOINT}/{toolboxName}/mcp?api-version={ApiVersion}</c>
/// </summary>
public IList<string> ToolboxNames { get; } = [];
/// <summary>
/// Gets or sets the Toolsets API version to use when constructing proxy URLs.
/// </summary>
public string ApiVersion { get; set; } = "2025-05-01-preview";
/// <summary>
/// Gets or sets a value indicating whether per-request toolbox markers (referenced via
/// <c>foundry-toolbox://</c> on the wire) are restricted to toolboxes pre-registered
/// via <see cref="ToolboxNames"/>. When <see langword="true"/> (the default), a request
/// that references an unknown toolbox is rejected. When <see langword="false"/>, the
/// server lazily opens an MCP connection for the referenced toolbox on first use and
/// caches it.
/// </summary>
public bool StrictMode { get; set; } = true;
/// <summary>
/// For testing only: overrides <c>FOUNDRY_AGENT_TOOLSET_ENDPOINT</c>.
/// Not part of the public API.
/// </summary>
internal string? EndpointOverride { get; set; }
}
@@ -0,0 +1,263 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Azure.Core;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Client;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// An <see cref="IHostedService"/> that eagerly connects to the Foundry Toolboxes MCP proxy at
/// container startup, discovers tools via <c>tools/list</c>, and caches them so they can be
/// injected into every <see cref="ChatOptions"/> by <see cref="AgentFrameworkResponseHandler"/>.
/// </summary>
/// <remarks>
/// <para>
/// When <c>FOUNDRY_AGENT_TOOLSET_ENDPOINT</c> is absent the service starts without error and
/// no tools are registered, keeping the container healthy per spec §2.
/// </para>
/// <para>
/// Startup eagerly connects to every name in <see cref="FoundryToolboxOptions.ToolboxNames"/>.
/// Beyond those, per-request toolbox markers (see <see cref="HostedMcpToolboxAITool"/>) are
/// resolved at request time through <see cref="GetToolboxToolsAsync"/>. Unknown toolboxes are
/// rejected when <see cref="FoundryToolboxOptions.StrictMode"/> is <see langword="true"/> and
/// lazily connected otherwise.
/// </para>
/// </remarks>
public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
{
private readonly FoundryToolboxOptions _options;
private readonly TokenCredential _credential;
private readonly ILogger<FoundryToolboxService> _logger;
private readonly Dictionary<string, CachedToolbox> _toolboxes = new(StringComparer.OrdinalIgnoreCase);
private readonly SemaphoreSlim _lazyOpenLock = new(1, 1);
private string? _resolvedEndpoint;
private string? _featuresHeader;
private string _agentName = "hosted-agent";
private string _agentVersion = "1.0.0";
/// <summary>
/// Gets the cached list of <see cref="AITool"/> instances discovered from all
/// pre-registered toolboxes. Always non-null after startup.
/// </summary>
public IReadOnlyList<AITool> Tools { get; private set; } = [];
/// <summary>
/// Initializes a new instance of <see cref="FoundryToolboxService"/>.
/// </summary>
public FoundryToolboxService(
IOptions<FoundryToolboxOptions> options,
TokenCredential credential,
ILogger<FoundryToolboxService>? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(credential);
this._options = options.Value;
this._credential = credential;
this._logger = logger ?? NullLogger<FoundryToolboxService>.Instance;
}
/// <inheritdoc/>
public async Task StartAsync(CancellationToken cancellationToken)
{
this._resolvedEndpoint = this._options.EndpointOverride
?? Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT");
if (string.IsNullOrEmpty(this._resolvedEndpoint))
{
this._logger.LogInformation("FOUNDRY_AGENT_TOOLSET_ENDPOINT is not set; toolbox support is disabled.");
this.Tools = [];
return;
}
this._featuresHeader = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_FEATURES");
this._agentName = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_NAME") ?? "hosted-agent";
this._agentVersion = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_VERSION") ?? "1.0.0";
if (this._options.ToolboxNames.Count == 0)
{
this._logger.LogInformation("No pre-registered toolbox names configured.");
this.Tools = [];
return;
}
var allTools = new List<AITool>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var toolboxName in this._options.ToolboxNames)
{
if (!seen.Add(toolboxName))
{
continue;
}
try
{
var cached = await this.OpenToolboxAsync(toolboxName, version: null, cancellationToken).ConfigureAwait(false);
this._toolboxes[toolboxName] = cached;
allTools.AddRange(cached.Tools);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
this._logger.LogError(
ex,
"Failed to connect to toolbox '{ToolboxName}'. Tools from this toolbox will not be available.",
toolboxName);
}
}
this.Tools = allTools;
}
/// <summary>
/// Resolves the tools for a per-request toolbox marker. Returns cached tools when the
/// toolbox has already been opened; otherwise honors
/// <see cref="FoundryToolboxOptions.StrictMode"/> to either reject or lazily open it.
/// </summary>
/// <param name="toolboxName">The Foundry toolbox name from the marker.</param>
/// <param name="version">
/// 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.
/// </param>
/// <param name="cancellationToken">The request cancellation token.</param>
/// <exception cref="InvalidOperationException">
/// Thrown when the toolbox is not pre-registered and <see cref="FoundryToolboxOptions.StrictMode"/>
/// is <see langword="true"/>, or when the toolbox endpoint is not configured.
/// </exception>
public async ValueTask<IReadOnlyList<AITool>> GetToolboxToolsAsync(
string toolboxName,
string? version,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(toolboxName);
if (this._toolboxes.TryGetValue(toolboxName, out var cached))
{
return cached.Tools;
}
if (this._options.StrictMode)
{
throw new InvalidOperationException(
$"Toolbox '{toolboxName}' is not pre-registered via AddFoundryToolboxes(...). " +
$"Either register it at startup or set {nameof(FoundryToolboxOptions.StrictMode)}=false to allow lazy resolution.");
}
if (string.IsNullOrEmpty(this._resolvedEndpoint))
{
throw new InvalidOperationException(
$"Cannot resolve toolbox '{toolboxName}': FOUNDRY_AGENT_TOOLSET_ENDPOINT is not set.");
}
await this._lazyOpenLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// Double-check after acquiring the lock to avoid duplicate opens under concurrency.
if (this._toolboxes.TryGetValue(toolboxName, out cached))
{
return cached.Tools;
}
cached = await this.OpenToolboxAsync(toolboxName, version, cancellationToken).ConfigureAwait(false);
this._toolboxes[toolboxName] = cached;
return cached.Tools;
}
finally
{
this._lazyOpenLock.Release();
}
}
private async Task<CachedToolbox> OpenToolboxAsync(
string toolboxName,
string? version,
CancellationToken cancellationToken)
{
var proxyUrl = $"{this._resolvedEndpoint!.TrimEnd('/')}/{toolboxName}/mcp?api-version={this._options.ApiVersion}";
if (this._logger.IsEnabled(LogLevel.Information))
{
this._logger.LogInformation("Connecting to toolbox '{ToolboxName}' at {ProxyUrl}.", toolboxName, proxyUrl);
}
var handler = new FoundryToolboxBearerTokenHandler(this._credential, this._featuresHeader)
{
InnerHandler = new HttpClientHandler()
};
var httpClient = new HttpClient(handler);
var transportOptions = new HttpClientTransportOptions
{
Endpoint = new Uri(proxyUrl),
Name = toolboxName,
};
var transport = new HttpClientTransport(transportOptions, httpClient);
var clientOptions = new McpClientOptions
{
ClientInfo = new()
{
Name = this._agentName,
Version = this._agentVersion
}
};
var client = await McpClient.CreateAsync(
transport,
clientOptions,
cancellationToken: cancellationToken).ConfigureAwait(false);
var mcpTools = await client.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
if (this._logger.IsEnabled(LogLevel.Information))
{
this._logger.LogInformation(
"Toolbox '{ToolboxName}': discovered {ToolCount} tool(s).",
toolboxName,
mcpTools.Count);
}
var wrapped = new List<AITool>(mcpTools.Count);
foreach (var tool in mcpTools)
{
wrapped.Add(new ConsentAwareMcpClientAIFunction(tool, toolboxName));
}
_ = version; // reserved for future version-specific routing; currently handled server-side by the proxy.
return new CachedToolbox(client, httpClient, wrapped);
}
/// <inheritdoc/>
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
foreach (var cached in this._toolboxes.Values)
{
await cached.Client.DisposeAsync().ConfigureAwait(false);
cached.HttpClient.Dispose();
}
this._toolboxes.Clear();
this._lazyOpenLock.Dispose();
}
private sealed record CachedToolbox(McpClient Client, HttpClient HttpClient, IReadOnlyList<AITool> Tools);
}
@@ -97,6 +97,37 @@ internal static class InputConverter
};
}
/// <summary>
/// Extracts any Foundry Toolbox markers (<c>foundry-toolbox://</c>) from the request's
/// MCP tool entries so the handler can resolve them server-side.
/// </summary>
/// <param name="request">The create response request.</param>
/// <returns>A list of (name, optional version) pairs, one per detected marker. Never <see langword="null"/>.</returns>
public static List<(string Name, string? Version)> ReadMcpToolboxMarkers(CreateResponse request)
{
var markers = new List<(string Name, string? Version)>();
if (request.Tools is null)
{
return markers;
}
foreach (var tool in request.Tools)
{
if (tool is not MCPTool mcp || mcp.ServerUrl is null)
{
continue;
}
if (HostedMcpToolboxAITool.TryParseToolboxAddress(mcp.ServerUrl.ToString(), out var name, out var version))
{
markers.Add((name!, version));
}
}
return markers;
}
private static ChatMessage? ConvertInputItemToMessage(Item item)
{
return item switch
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Carries OAuth consent information for a single tool call that returned JSON-RPC error -32006.
/// </summary>
/// <param name="ToolboxName">The toolbox name that owns the tool.</param>
/// <param name="ToolName">Fully-qualified tool name (e.g., <c>logicapps.send_email</c>).</param>
/// <param name="ConsentUrl">The OAuth consent URL the user must visit.</param>
internal sealed record McpConsentInfo(string ToolboxName, string ToolName, string ConsentUrl);
/// <summary>
/// Per-request mutable state shared between <see cref="ConsentAwareMcpClientAIFunction"/> (child context)
/// and <see cref="AgentFrameworkResponseHandler"/> (parent context) via <see cref="McpConsentContext.Current"/>.
/// </summary>
/// <remarks>
/// Because <see cref="AsyncLocal{T}"/> only flows values DOWN from parent to children,
/// we use a shared reference type so children can mutate it and the parent observes the mutations.
/// </remarks>
internal sealed class RequestConsentState
{
/// <summary>Consent information set by the tool wrapper when -32006 is detected.</summary>
internal McpConsentInfo? Pending { get; set; }
/// <summary>The linked CTS to cancel when consent is required.</summary>
internal CancellationTokenSource? CancellationSource { get; set; }
}
/// <summary>
/// Thread-static (AsyncLocal) context that enables <see cref="ConsentAwareMcpClientAIFunction"/>
/// to signal a consent error back to <see cref="AgentFrameworkResponseHandler"/> through the
/// <see cref="FunctionInvokingChatClient"/> tool loop.
/// </summary>
internal static class McpConsentContext
{
/// <summary>
/// Holds the shared <see cref="RequestConsentState"/> for the current request.
/// Set once by the handler; read and mutated by the tool wrapper.
/// </summary>
internal static readonly AsyncLocal<RequestConsentState?> Current = new();
}
@@ -4,6 +4,8 @@ using System;
using System.Reflection;
using System.Threading.Tasks;
using Azure.AI.AgentServer.Responses;
using Azure.Core;
using Azure.Identity;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
@@ -97,6 +99,75 @@ public static class FoundryHostingExtensions
return services;
}
/// <summary>
/// Registers the Foundry Toolbox service, which eagerly connects to the Foundry Toolboxes
/// MCP proxy at startup and provides MCP tools to <see cref="AgentFrameworkResponseHandler"/>.
/// </summary>
/// <remarks>
/// <para>
/// Each string in <paramref name="toolboxNames"/> is a toolbox name registered in the Foundry
/// project. The proxy URL per toolbox is constructed as:
/// <c>{FOUNDRY_AGENT_TOOLSET_ENDPOINT}/{toolboxName}/mcp?api-version=2025-05-01-preview</c>
/// </para>
/// <para>
/// When <c>FOUNDRY_AGENT_TOOLSET_ENDPOINT</c> is absent, startup succeeds without error and
/// no tools are loaded (the container remains healthy per spec §2).
/// </para>
/// <para>
/// Example:
/// <code>
/// builder.Services.AddFoundryToolboxes("my-toolbox", "another-toolbox");
/// </code>
/// </para>
/// </remarks>
/// <param name="services">The service collection.</param>
/// <param name="toolboxNames">Names of the Foundry toolboxes to connect to.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFoundryToolboxes(
this IServiceCollection services,
params string[] toolboxNames)
=> services.AddFoundryToolboxes(configureOptions: null, toolboxNames);
/// <summary>
/// Registers the Foundry Toolbox service with additional options configuration.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configureOptions">Callback to further configure <see cref="FoundryToolboxOptions"/> (e.g. set <see cref="FoundryToolboxOptions.StrictMode"/>).</param>
/// <param name="toolboxNames">Names of the Foundry toolboxes to pre-register at startup.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFoundryToolboxes(
this IServiceCollection services,
Action<FoundryToolboxOptions>? configureOptions,
params string[] toolboxNames)
{
ArgumentNullException.ThrowIfNull(services);
services.Configure<FoundryToolboxOptions>(opt =>
{
foreach (var name in toolboxNames)
{
if (!string.IsNullOrWhiteSpace(name))
{
opt.ToolboxNames.Add(name);
}
}
configureOptions?.Invoke(opt);
});
// Register DefaultAzureCredential as the default TokenCredential if not already registered
services.TryAddSingleton<TokenCredential>(_ => new DefaultAzureCredential());
// Register FoundryToolboxService as a singleton so it can be injected into the handler
services.TryAddSingleton<FoundryToolboxService>();
// AddHostedService uses TryAddEnumerable internally, so calling AddFoundryToolboxes
// multiple times will not invoke StartAsync twice on the same singleton.
services.AddHostedService(sp => sp.GetRequiredService<FoundryToolboxService>());
return services;
}
/// <summary>
/// Maps the Responses API routes for the agent-framework handler to the endpoint routing pipeline.
/// </summary>
@@ -36,6 +36,8 @@
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETCoreApp'">
<PackageReference Include="Azure.AI.AgentServer.Responses" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="ModelContextProtocol" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,163 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Foundry.UnitTests;
public class HostedMcpToolboxAIToolTests
{
[Fact]
public void Ctor_NameOnly_BuildsMarkerAddress()
{
var tool = new HostedMcpToolboxAITool("my-toolbox");
Assert.Equal("my-toolbox", tool.ToolboxName);
Assert.Null(tool.Version);
Assert.Equal("my-toolbox", tool.ServerName);
Assert.Equal("foundry-toolbox://my-toolbox", tool.ServerAddress);
Assert.Equal("mcp", tool.Name);
}
[Fact]
public void Ctor_WithVersion_IncludesVersionQuery()
{
var tool = new HostedMcpToolboxAITool("my-toolbox", "v3");
Assert.Equal("v3", tool.Version);
Assert.Equal("foundry-toolbox://my-toolbox?version=v3", tool.ServerAddress);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Ctor_InvalidName_Throws(string? name)
{
Assert.ThrowsAny<ArgumentException>(() => new HostedMcpToolboxAITool(name!));
}
[Fact]
public void TryParseToolboxAddress_NameOnly_ReturnsTrue()
{
var ok = HostedMcpToolboxAITool.TryParseToolboxAddress(
"foundry-toolbox://my-toolbox", out var name, out var version);
Assert.True(ok);
Assert.Equal("my-toolbox", name);
Assert.Null(version);
}
[Fact]
public void TryParseToolboxAddress_WithVersion_ExtractsVersion()
{
var ok = HostedMcpToolboxAITool.TryParseToolboxAddress(
"foundry-toolbox://my-toolbox?version=v3", out var name, out var version);
Assert.True(ok);
Assert.Equal("my-toolbox", name);
Assert.Equal("v3", version);
}
[Theory]
[InlineData("https://example.com/mcp")]
[InlineData("not-a-url")]
[InlineData("")]
[InlineData(null)]
public void TryParseToolboxAddress_NonMarker_ReturnsFalse(string? address)
{
var ok = HostedMcpToolboxAITool.TryParseToolboxAddress(address, out var name, out var version);
Assert.False(ok);
Assert.Null(name);
Assert.Null(version);
}
[Fact]
public void TryParseToolboxAddress_RoundTripsFromBuild()
{
var address = HostedMcpToolboxAITool.BuildAddress("box", "2025-06-01");
var ok = HostedMcpToolboxAITool.TryParseToolboxAddress(address, out var name, out var version);
Assert.True(ok);
Assert.Equal("box", name);
Assert.Equal("2025-06-01", version);
}
[Fact]
public void FoundryAITool_CreateHostedMcpToolbox_ReturnsMarker()
{
var tool = FoundryAITool.CreateHostedMcpToolbox("my-toolbox", "v1");
var marker = Assert.IsType<HostedMcpToolboxAITool>(tool);
Assert.Equal("my-toolbox", marker.ToolboxName);
Assert.Equal("v1", marker.Version);
}
[Fact]
public void FoundryAITool_CreateHostedMcpToolbox_FromToolboxRecord_UsesNameAndDefaultVersion()
{
var record = Azure.AI.Projects.Agents.ProjectsAgentsModelFactory.ToolboxRecord(
id: "tbx-123",
name: "calendar-tools",
defaultVersion: "v2");
var tool = FoundryAITool.CreateHostedMcpToolbox(record);
var marker = Assert.IsType<HostedMcpToolboxAITool>(tool);
Assert.Equal("calendar-tools", marker.ToolboxName);
Assert.Equal("v2", marker.Version);
Assert.Equal("foundry-toolbox://calendar-tools?version=v2", marker.ServerAddress);
}
[Fact]
public void FoundryAITool_CreateHostedMcpToolbox_FromToolboxRecord_NullDefaultVersionOmitsQuery()
{
var record = Azure.AI.Projects.Agents.ProjectsAgentsModelFactory.ToolboxRecord(
id: "tbx-abc",
name: "finance-tools",
defaultVersion: null);
var tool = FoundryAITool.CreateHostedMcpToolbox(record);
var marker = Assert.IsType<HostedMcpToolboxAITool>(tool);
Assert.Equal("finance-tools", marker.ToolboxName);
Assert.Null(marker.Version);
Assert.Equal("foundry-toolbox://finance-tools", marker.ServerAddress);
}
[Fact]
public void FoundryAITool_CreateHostedMcpToolbox_FromToolboxRecord_Null_Throws()
{
Assert.Throws<ArgumentNullException>(
() => FoundryAITool.CreateHostedMcpToolbox((Azure.AI.Projects.Agents.ToolboxRecord)null!));
}
[Fact]
public void FoundryAITool_CreateHostedMcpToolbox_FromToolboxVersion_UsesNameAndVersion()
{
var version = Azure.AI.Projects.Agents.ProjectsAgentsModelFactory.ToolboxVersion(
metadata: null,
id: "ver-1",
name: "hr-tools",
version: "2025-09-01",
description: "HR toolbox",
createdAt: DateTimeOffset.UtcNow,
tools: null,
policies: null);
var tool = FoundryAITool.CreateHostedMcpToolbox(version);
var marker = Assert.IsType<HostedMcpToolboxAITool>(tool);
Assert.Equal("hr-tools", marker.ToolboxName);
Assert.Equal("2025-09-01", marker.Version);
Assert.Equal("foundry-toolbox://hr-tools?version=2025-09-01", marker.ServerAddress);
}
[Fact]
public void FoundryAITool_CreateHostedMcpToolbox_FromToolboxVersion_Null_Throws()
{
Assert.Throws<ArgumentNullException>(
() => FoundryAITool.CreateHostedMcpToolbox((Azure.AI.Projects.Agents.ToolboxVersion)null!));
}
}
@@ -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<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));
}
}
}
@@ -0,0 +1,69 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Azure.Core;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.Options;
using Moq;
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
public class FoundryToolboxServiceTests
{
[Fact]
public async Task GetToolboxToolsAsync_StrictMode_ThrowsForUnknownToolboxAsync()
{
var options = new FoundryToolboxOptions { StrictMode = true };
var service = new FoundryToolboxService(
Options.Create(options),
Mock.Of<TokenCredential>());
// Act + Assert: no StartAsync so Tools is empty; unknown name in strict mode throws.
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
async () => await service.GetToolboxToolsAsync("missing", version: null, CancellationToken.None));
Assert.Contains("missing", ex.Message, StringComparison.Ordinal);
Assert.Contains("StrictMode", ex.Message, StringComparison.Ordinal);
}
[Fact]
public async Task GetToolboxToolsAsync_NonStrictMode_RequiresEndpointAsync()
{
var options = new FoundryToolboxOptions { StrictMode = false };
var service = new FoundryToolboxService(
Options.Create(options),
Mock.Of<TokenCredential>());
// Without calling StartAsync, endpoint is not resolved so lazy-open fails clearly.
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
async () => await service.GetToolboxToolsAsync("missing", version: null, CancellationToken.None));
Assert.Contains("FOUNDRY_AGENT_TOOLSET_ENDPOINT", ex.Message, StringComparison.Ordinal);
}
[Fact]
public async Task StartAsync_WithoutEndpoint_LeavesToolsEmptyAsync()
{
// Ensure env var is not set (tests may run in any CI environment)
var saved = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT");
Environment.SetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT", null);
try
{
var options = new FoundryToolboxOptions();
options.ToolboxNames.Add("any");
var service = new FoundryToolboxService(
Options.Create(options),
Mock.Of<TokenCredential>());
await service.StartAsync(CancellationToken.None);
Assert.Empty(service.Tools);
}
finally
{
Environment.SetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT", saved);
}
}
}
@@ -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);
}
}