From 215a0ad4737213822f8e6396cd948444052f0c52 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Fri, 17 Apr 2026 19:43:16 +0100 Subject: [PATCH] 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. --- .../FoundryAITool.cs | 18 ++ .../HostedMcpToolboxAITool.cs | 152 ++++++++++++ .../Hosting/AgentFrameworkResponseHandler.cs | 72 +++++- .../Hosting/FoundryToolboxOptions.cs | 10 + .../Hosting/FoundryToolboxService.cs | 234 ++++++++++++------ .../Hosting/InputConverter.cs | 31 +++ .../Hosting/ServiceCollectionExtensions.cs | 15 ++ .../HostedMcpToolboxAIToolTests.cs | 96 +++++++ .../Hosting/FoundryToolboxServiceTests.cs | 69 ++++++ 9 files changed, 611 insertions(+), 86 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry/HostedMcpToolboxAITool.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/HostedMcpToolboxAIToolTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryToolboxServiceTests.cs 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); + } + } +}