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.
This commit is contained in:
Roger Barreto
2026-04-17 19:43:16 +01:00
Unverified
parent 1f4bcd6c88
commit 215a0ad473
9 changed files with 611 additions and 86 deletions
@@ -112,6 +112,24 @@ 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>
/// <remarks>
/// <para>
/// Consumers who already hold a <c>ToolboxRecord</c> or <c>ToolboxVersion</c> from
/// <c>Azure.AI.Projects.Agents</c> can pass <c>record.Name</c> together with
/// <c>record.DefaultVersion</c> (or <c>version.Name</c>/<c>version.Version</c>) to this
/// factory.
/// </para>
/// </remarks>
public static AITool CreateHostedMcpToolbox(string toolboxName, string? version = null)
=> new HostedMcpToolboxAITool(toolboxName, version);
// --- OpenAI SDK ResponseTool factories ---
/// <summary>
@@ -0,0 +1,152 @@
// 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.</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={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;
}
}
@@ -97,13 +97,77 @@ public class AgentFrameworkResponseHandler : ResponseHandler
var chatOptions = InputConverter.ConvertToChatOptions(request);
chatOptions.Instructions = request.Instructions;
// Inject Foundry Toolbox tools when the toolbox service is available
// 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)
{
var toolboxTools = this._toolboxService.Tools;
if (toolboxTools.Count > 0)
List<AITool>? toolsToAdd = null;
if (this._toolboxService.Tools.Count > 0)
{
chatOptions.Tools = [.. chatOptions.Tools ?? [], .. toolboxTools];
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];
}
}
@@ -22,6 +22,16 @@ public sealed class FoundryToolboxOptions
/// </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.
@@ -18,17 +18,19 @@ 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"/>.
/// 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 returns
/// an empty tool list, keeping the container healthy per spec §2.
/// 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>
/// Initialization is performed in <see cref="StartAsync"/> so the readiness probe is only satisfied
/// after all configured toolboxes are connected and their tools discovered (spec §3.1 SHOULD).
/// 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
@@ -37,12 +39,17 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
private readonly TokenCredential _credential;
private readonly ILogger<FoundryToolboxService> _logger;
private readonly List<McpClient> _clients = [];
private readonly List<HttpClient> _httpClients = [];
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 connected toolboxes.
/// Always non-null after startup; returns an empty list when no toolbox endpoint is configured.
/// 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; } = [];
@@ -65,30 +72,28 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
/// <inheritdoc/>
public async Task StartAsync(CancellationToken cancellationToken)
{
var endpoint = this._options.EndpointOverride
this._resolvedEndpoint = this._options.EndpointOverride
?? Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT");
if (string.IsNullOrEmpty(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 toolbox names configured; toolbox support is disabled.");
this._logger.LogInformation("No pre-registered toolbox names configured.");
this.Tools = [];
return;
}
var featuresHeader = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_FEATURES");
var agentName = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_NAME") ?? "hosted-agent";
var agentVersion = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_VERSION") ?? "1.0.0";
var allTools = new List<AITool>();
// Deduplicate toolbox names to avoid duplicate MCP clients and ambiguous tool exposure
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var toolboxName in this._options.ToolboxNames)
@@ -98,61 +103,11 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
continue;
}
var proxyUrl = $"{endpoint.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);
}
try
{
var handler = new FoundryToolboxBearerTokenHandler(this._credential, featuresHeader)
{
InnerHandler = new HttpClientHandler()
};
var httpClient = new HttpClient(handler);
this._httpClients.Add(httpClient);
var transportOptions = new HttpClientTransportOptions
{
Endpoint = new Uri(proxyUrl),
Name = toolboxName,
};
var transport = new HttpClientTransport(transportOptions, httpClient);
var clientOptions = new McpClientOptions
{
ClientInfo = new()
{
Name = agentName,
Version = agentVersion
}
};
var client = await McpClient.CreateAsync(
transport,
clientOptions,
cancellationToken: cancellationToken).ConfigureAwait(false);
this._clients.Add(client);
var tools = await client.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
if (this._logger.IsEnabled(LogLevel.Information))
{
this._logger.LogInformation(
"Toolbox '{ToolboxName}': discovered {ToolCount} tool(s).",
toolboxName,
tools.Count);
}
foreach (var tool in tools)
{
allTools.Add(new ConsentAwareMcpClientAIFunction(tool, toolboxName));
}
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)
{
@@ -166,24 +121,139 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
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; ignored when matching a pre-registered entry.</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 client in this._clients)
foreach (var cached in this._toolboxes.Values)
{
await client.DisposeAsync().ConfigureAwait(false);
await cached.Client.DisposeAsync().ConfigureAwait(false);
cached.HttpClient.Dispose();
}
this._clients.Clear();
foreach (var httpClient in this._httpClients)
{
httpClient.Dispose();
}
this._httpClients.Clear();
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
@@ -126,6 +126,19 @@ public static class FoundryHostingExtensions
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);
@@ -138,6 +151,8 @@ public static class FoundryHostingExtensions
opt.ToolboxNames.Add(name);
}
}
configureOptions?.Invoke(opt);
});
// Register DefaultAzureCredential as the default TokenCredential if not already registered
@@ -0,0 +1,96 @@
// 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);
}
}
@@ -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);
}
}
}