mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
+69
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user