// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
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 Microsoft.Shared.DiagnosticIds;
using ModelContextProtocol.Client;
namespace Microsoft.Agents.AI.Foundry.Hosting;
///
/// An that eagerly connects to the Foundry Toolboxes MCP proxy at
/// container startup, discovers tools via tools/list, and caches them so they can be
/// injected into every by .
///
///
///
/// When FOUNDRY_AGENT_TOOLSET_ENDPOINT is absent the service starts without error and
/// no tools are registered, keeping the container healthy per spec §2.
///
///
/// Startup eagerly connects to every name in .
/// Beyond those, per-request toolbox markers (see ) are
/// resolved at request time through . Unknown toolboxes are
/// rejected when is and
/// lazily connected otherwise.
///
///
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
{
private readonly FoundryToolboxOptions _options;
private readonly TokenCredential _credential;
private readonly ILogger _logger;
private readonly Dictionary _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";
///
/// Gets the cached list of instances discovered from all
/// pre-registered toolboxes. Always non-null after startup.
///
public IReadOnlyList Tools { get; private set; } = [];
///
/// Initializes a new instance of .
///
public FoundryToolboxService(
IOptions options,
TokenCredential credential,
ILogger? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(credential);
this._options = options.Value;
this._credential = credential;
this._logger = logger ?? NullLogger.Instance;
}
///
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();
var seen = new HashSet(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)
{
if (this._logger.IsEnabled(LogLevel.Error))
{
this._logger.LogError(
ex,
"Failed to connect to toolbox '{ToolboxName}'. Tools from this toolbox will not be available.",
toolboxName);
}
}
}
this.Tools = allTools;
}
///
/// Resolves the tools for a per-request toolbox marker. Returns cached tools when the
/// toolbox has already been opened; otherwise honors
/// to either reject or lazily open it.
///
/// The Foundry toolbox name from the marker.
///
/// 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
/// is , or when the toolbox endpoint is not configured.
///
public async ValueTask> 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 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(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);
}
///
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
///
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 Tools);
}