diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAITool.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAITool.cs
index 7721f8c013..a16b205e4f 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAITool.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAITool.cs
@@ -112,6 +112,24 @@ public static class FoundryAITool
public static AITool CreateA2ATool(Uri baseUri, string? agentCardPath = null)
=> ProjectsAgentTool.CreateA2ATool(baseUri, agentCardPath).AsAITool();
+ ///
+ /// Creates an marker that references a Foundry Toolbox by name so
+ /// the hosted server side can resolve and expose its MCP tools for a single request.
+ ///
+ /// The Foundry toolbox name.
+ /// Optional pinned toolbox version. When , the project's default version is used.
+ /// An marker backed by .
+ ///
+ ///
+ /// Consumers who already hold a ToolboxRecord or ToolboxVersion from
+ /// Azure.AI.Projects.Agents can pass record.Name together with
+ /// record.DefaultVersion (or version.Name/version.Version) to this
+ /// factory.
+ ///
+ ///
+ public static AITool CreateHostedMcpToolbox(string toolboxName, string? version = null)
+ => new HostedMcpToolboxAITool(toolboxName, version);
+
// --- OpenAI SDK ResponseTool factories ---
///
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/HostedMcpToolboxAITool.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/HostedMcpToolboxAITool.cs
new file mode 100644
index 0000000000..79d0755e87
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/HostedMcpToolboxAITool.cs
@@ -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;
+
+///
+/// A marker that identifies a Foundry Toolbox by name
+/// (and optional version) on the OpenAI Responses mcp wire format.
+///
+///
+///
+/// The hosted server recognizes this marker by its
+/// scheme () and resolves it to the set of MCP tools exposed by the
+/// matching toolbox registered in the Foundry project.
+///
+///
+/// Callers should not construct this type directly. Use one of the
+/// FoundryAITool.CreateHostedMcpToolbox(...) factory overloads.
+///
+///
+[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
+public sealed class HostedMcpToolboxAITool : HostedMcpServerTool
+{
+ ///
+ /// The URI scheme used to identify Foundry Toolbox markers on the wire.
+ ///
+ public const string UriScheme = "foundry-toolbox";
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The Foundry toolbox name.
+ /// Optional pinned toolbox version. When , the project's default version is used.
+ public HostedMcpToolboxAITool(string toolboxName, string? version = null)
+ : base(
+ serverName: NotNullOrWhitespace(toolboxName, nameof(toolboxName)),
+ serverAddress: BuildAddress(toolboxName, version))
+ {
+ this.ToolboxName = toolboxName;
+ this.Version = version;
+ }
+
+ ///
+ /// Gets the Foundry toolbox name.
+ ///
+ public string ToolboxName { get; }
+
+ ///
+ /// Gets the pinned toolbox version, or to use the project's default.
+ ///
+ public string? Version { get; }
+
+ ///
+ /// Builds the toolbox marker address: foundry-toolbox://{name}[?version={v}].
+ ///
+ public static string BuildAddress(string toolboxName, string? version)
+ {
+ _ = NotNullOrWhitespace(toolboxName, nameof(toolboxName));
+
+ return string.IsNullOrEmpty(version)
+ ? $"{UriScheme}://{toolboxName}"
+ : $"{UriScheme}://{toolboxName}?version={version}";
+ }
+
+ ///
+ /// Attempts to parse a toolbox marker address into its name and optional version components.
+ ///
+ /// The to inspect.
+ /// When this method returns , the parsed toolbox name.
+ /// When this method returns , the optional version, or .
+ /// if is a Foundry toolbox marker; otherwise .
+ 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;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs
index 8dfd395e4c..b9a0ebeb0f 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs
@@ -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? 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(StringComparer.OrdinalIgnoreCase);
+ string? resolutionError = null;
+
+ foreach (var (name, version) in markers)
+ {
+ if (!seen.Add(name))
+ {
+ continue;
+ }
+
+ IReadOnlyList? 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];
}
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxOptions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxOptions.cs
index 68ae41762e..79ea9cc7b7 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxOptions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxOptions.cs
@@ -22,6 +22,16 @@ public sealed class FoundryToolboxOptions
///
public string ApiVersion { get; set; } = "2025-05-01-preview";
+ ///
+ /// Gets or sets a value indicating whether per-request toolbox markers (referenced via
+ /// foundry-toolbox:// on the wire) are restricted to toolboxes pre-registered
+ /// via . When (the default), a request
+ /// that references an unknown toolbox is rejected. When , the
+ /// server lazily opens an MCP connection for the referenced toolbox on first use and
+ /// caches it.
+ ///
+ public bool StrictMode { get; set; } = true;
+
///
/// For testing only: overrides FOUNDRY_AGENT_TOOLSET_ENDPOINT.
/// Not part of the public API.
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxService.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxService.cs
index cd9c736e31..315426574f 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxService.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxService.cs
@@ -18,17 +18,19 @@ 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
-/// .
+/// injected into every by .
///
///
///
-/// When FOUNDRY_AGENT_TOOLSET_ENDPOINT is absent the service starts without error and returns
-/// an empty tool list, keeping the container healthy per spec §2.
+/// When FOUNDRY_AGENT_TOOLSET_ENDPOINT is absent the service starts without error and
+/// no tools are registered, keeping the container healthy per spec §2.
///
///
-/// Initialization is performed in 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 .
+/// Beyond those, per-request toolbox markers (see ) are
+/// resolved at request time through . Unknown toolboxes are
+/// rejected when is and
+/// lazily connected otherwise.
///
///
public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
@@ -37,12 +39,17 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
private readonly TokenCredential _credential;
private readonly ILogger _logger;
- private readonly List _clients = [];
- private readonly List _httpClients = [];
+ 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 connected toolboxes.
- /// Always non-null after startup; returns an empty list when no toolbox endpoint is configured.
+ /// Gets the cached list of instances discovered from all
+ /// pre-registered toolboxes. Always non-null after startup.
///
public IReadOnlyList Tools { get; private set; } = [];
@@ -65,30 +72,28 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
///
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();
-
- // Deduplicate toolbox names to avoid duplicate MCP clients and ambiguous tool exposure
var seen = new HashSet(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;
}
+ ///
+ /// 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; ignored when matching a pre-registered entry.
+ /// 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 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 Tools);
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InputConverter.cs
index 1d8be8f590..cc97049ae9 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InputConverter.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InputConverter.cs
@@ -97,6 +97,37 @@ internal static class InputConverter
};
}
+ ///
+ /// Extracts any Foundry Toolbox markers (foundry-toolbox://) from the request's
+ /// MCP tool entries so the handler can resolve them server-side.
+ ///
+ /// The create response request.
+ /// A list of (name, optional version) pairs, one per detected marker. Never .
+ 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
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs
index 3a924d6899..7dc14bec3c 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs
@@ -126,6 +126,19 @@ public static class FoundryHostingExtensions
public static IServiceCollection AddFoundryToolboxes(
this IServiceCollection services,
params string[] toolboxNames)
+ => services.AddFoundryToolboxes(configureOptions: null, toolboxNames);
+
+ ///
+ /// Registers the Foundry Toolbox service with additional options configuration.
+ ///
+ /// The service collection.
+ /// Callback to further configure (e.g. set ).
+ /// Names of the Foundry toolboxes to pre-register at startup.
+ /// The service collection for chaining.
+ public static IServiceCollection AddFoundryToolboxes(
+ this IServiceCollection services,
+ Action? 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
diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/HostedMcpToolboxAIToolTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/HostedMcpToolboxAIToolTests.cs
new file mode 100644
index 0000000000..d6fbd53df5
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/HostedMcpToolboxAIToolTests.cs
@@ -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(() => 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(tool);
+ Assert.Equal("my-toolbox", marker.ToolboxName);
+ Assert.Equal("v1", marker.Version);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryToolboxServiceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryToolboxServiceTests.cs
new file mode 100644
index 0000000000..24f7433c4e
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryToolboxServiceTests.cs
@@ -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());
+
+ // Act + Assert: no StartAsync so Tools is empty; unknown name in strict mode throws.
+ var ex = await Assert.ThrowsAsync(
+ 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());
+
+ // Without calling StartAsync, endpoint is not resolved so lazy-open fails clearly.
+ var ex = await Assert.ThrowsAsync(
+ 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());
+
+ await service.StartAsync(CancellationToken.None);
+
+ Assert.Empty(service.Tools);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT", saved);
+ }
+ }
+}