File: Toolbox\FoundryToolboxToolDefinition.cs
Web Access
Project: src\src\Aspire.Hosting.Foundry\Aspire.Hosting.Foundry.csproj (Aspire.Hosting.Foundry)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.ClientModel.Primitives;
using System.Text.Json;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Azure;
using Azure.AI.Projects.Agents;
 
namespace Aspire.Hosting.Foundry;
 
/// <summary>
/// Base type for Microsoft Foundry Toolbox tool definitions.
/// </summary>
internal abstract class FoundryToolboxToolDefinition
{
    private protected FoundryToolboxToolDefinition(string name)
    {
        ArgumentException.ThrowIfNullOrEmpty(name);
 
        Name = name;
    }
 
    /// <summary>
    /// Gets the tool name.
    /// </summary>
    public string Name { get; }
 
    internal abstract ValueTask<ResolvedFoundryToolboxTool> ResolveAsync(CancellationToken cancellationToken);
}
 
/// <summary>
/// Describes a web search tool in a Microsoft Foundry Toolbox.
/// </summary>
internal sealed class FoundryToolboxWebSearchToolDefinition : FoundryToolboxToolDefinition
{
    internal FoundryToolboxWebSearchToolDefinition(string name, string? description = null)
        : base(name)
    {
        Description = description;
    }
 
    public string? Description { get; }
 
    internal override ValueTask<ResolvedFoundryToolboxTool> ResolveAsync(CancellationToken cancellationToken)
    {
        // Build the OpenAI Responses "web_search" tool wire JSON by hand and read it back as a
        // ProjectsAgentTool, bypassing ModelReaderWriter.Write on an OpenAI.Responses tool entirely.
        //
        // The natural implementation here is:
        //
        //   var openAiTool = OpenAI.Responses.ResponseTool.CreateWebSearchTool();
        //   var agentTool  = openAiTool.AsAgentTool(); // round-trips via ModelReaderWriter.Write
        //
        // That works in a normal .NET process where every assembly is loaded once. It does NOT
        // work in the polyglot (e.g. JavaScript/TypeScript) AppHostServer host process. That host
        // ships its own copy of OpenAI + System.ClientModel inside its application folder, and
        // loads hosting integrations into an isolated AssemblyLoadContext (see Aspire.Hosting.RemoteHost
        // IntegrationLoadContext). Today the host carries System.ClientModel 1.10.0 while this
        // integration is built against System.ClientModel 1.11.0; the load policy resolves the
        // newer SCM into the probe ALC but keeps OpenAI bound to the older SCM in the default ALC.
        // The two SCMs surface as distinct CLR assemblies, so the WebSearchTool instance (loaded
        // in the default ALC) implements IPersistableModel<WebSearchTool> against default-ALC SCM,
        // while ModelReaderWriter.Write<WebSearchTool> runs from probe-ALC SCM and checks
        // `model is IPersistableModel<T>` against probe-ALC SCM. The interface check returns false
        // and SCM throws the misleading "WebSearchTool must implement IEnumerable or IPersistableModel".
        //
        // Constructing the wire JSON ourselves keeps everything inside types that are shared across
        // ALCs (BCL + Azure.AI.Projects.Agents in the probe ALC), so the cross-ALC mismatch never
        // comes into play. The Read side is fine because AzureAIProjectsAgentsContext is resolved
        // from the same ALC as the SCM it talks to.
        //
        // Toolbox tools support an additional "name" field that is not modeled by the current
        // Azure.AI.Projects.Agents SDK but is preserved through its additional-properties bag:
        //   {"type":"web_search","name":"web-search"}
        // See https://learn.microsoft.com/azure/foundry/agents/how-to/tools/toolbox#multiple-tool-types.
        using var stream = new MemoryStream();
        using (var writer = new Utf8JsonWriter(stream))
        {
            writer.WriteStartObject();
            writer.WriteString("type", "web_search");
            writer.WriteString("name", Name);
            if (Description is not null)
            {
                writer.WriteString("description", Description);
            }
            writer.WriteEndObject();
        }
 
        var json = BinaryData.FromBytes(stream.ToArray());
        var agentTool = ModelReaderWriter.Read<ProjectsAgentTool>(json, ModelReaderWriterOptions.Json, AzureAIProjectsAgentsContext.Default);
        return new ValueTask<ResolvedFoundryToolboxTool>(
            new ResolvedFoundryToolboxTool(Name, agentTool!, json.ToString()));
    }
}
 
/// <summary>
/// Describes an MCP tool in a Microsoft Foundry Toolbox.
/// </summary>
internal sealed class FoundryToolboxMcpToolDefinition : FoundryToolboxToolDefinition
{
    internal static bool IsFoundryReachableHttpsEndpoint(Uri endpointUri)
    {
        var host = endpointUri.Host.TrimEnd('.');
 
        return endpointUri.Scheme == Uri.UriSchemeHttps &&
            string.IsNullOrEmpty(endpointUri.UserInfo) &&
            !endpointUri.IsLoopback &&
            !host.Equals("localhost", StringComparison.OrdinalIgnoreCase) &&
            !host.EndsWith(".localhost", StringComparison.OrdinalIgnoreCase);
    }
 
    internal FoundryToolboxMcpToolDefinition(
        string name,
        ReferenceExpression endpointExpression,
        FoundryToolboxMcpToolOptions? options = null)
        : base(name)
    {
        ArgumentNullException.ThrowIfNull(endpointExpression);
 
        EndpointExpression = endpointExpression;
        ServerLabel = options?.ServerLabel ?? name;
        ServerDescription = options?.ServerDescription;
        ApprovalPolicy = ResolvedFoundryToolboxMcpApprovalPolicy.Create(options?.ApprovalPolicy);
 
        ArgumentException.ThrowIfNullOrWhiteSpace(ServerLabel);
        if (ServerDescription is not null)
        {
            ArgumentException.ThrowIfNullOrWhiteSpace(ServerDescription);
        }
    }
 
    /// <summary>
    /// Gets the MCP endpoint expression for the tool.
    /// </summary>
    public ReferenceExpression EndpointExpression { get; }
 
    public string ServerLabel { get; }
 
    public string? ServerDescription { get; }
 
    internal ResolvedFoundryToolboxMcpApprovalPolicy? ApprovalPolicy { get; }
 
    internal override async ValueTask<ResolvedFoundryToolboxTool> ResolveAsync(CancellationToken cancellationToken)
    {
        var endpoint = await EndpointExpression.GetValueAsync(cancellationToken).ConfigureAwait(false);
        if (string.IsNullOrEmpty(endpoint))
        {
            throw new InvalidOperationException(
                $"MCP tool '{Name}' does not have a resolvable endpoint URI.");
        }
 
        if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri) ||
            !IsFoundryReachableHttpsEndpoint(endpointUri))
        {
            throw new InvalidOperationException(
                $"MCP tool '{Name}' must resolve to a Foundry-reachable absolute HTTPS endpoint.");
        }
 
        // Build the OpenAI Responses "mcp" tool wire JSON by hand and read it back as a
        // ProjectsAgentTool. See the comment on FoundryToolboxWebSearchToolDefinition for the
        // underlying cross-ALC System.ClientModel version mismatch that makes the natural
        // `ResponseTool.CreateMcpTool(...).AsAgentTool()` round-trip throw in the polyglot
        // (e.g. JavaScript/TypeScript) AppHostServer host process. Constructing the JSON
        // ourselves keeps everything inside types that are consistent across the integration's
        // ALC (BCL + Azure.AI.Projects.Agents + that ALC's copy of System.ClientModel).
        //
        // OpenAI Responses "mcp" tool wire shape:
        //   {
        //     "type": "mcp",
        //     "server_label": "<required>",
        //     "server_url":   "<absolute uri>" // required for hosted MCP
        //   }
        // See https://platform.openai.com/docs/api-reference/responses/create#responses-create-tools
        // and openai-dotnet's McpTool.Serialization.cs for the exact property names.
        using var stream = new MemoryStream();
        using (var writer = new Utf8JsonWriter(stream))
        {
            writer.WriteStartObject();
            writer.WriteString("type", "mcp");
            writer.WriteString("server_label", ServerLabel);
            writer.WriteString("server_url", endpointUri.AbsoluteUri);
            if (ServerDescription is not null)
            {
                writer.WriteString("server_description", ServerDescription);
            }
            if (ApprovalPolicy is not null)
            {
                writer.WritePropertyName("require_approval");
                ApprovalPolicy.WriteTo(writer);
            }
            writer.WriteEndObject();
        }
 
        var json = BinaryData.FromBytes(stream.ToArray());
        var tool = ModelReaderWriter.Read<ProjectsAgentTool>(
            json,
            ModelReaderWriterOptions.Json,
            AzureAIProjectsAgentsContext.Default)!;
 
        return new ResolvedFoundryToolboxTool(Name, tool, json.ToString(), ServerLabel);
    }
}
 
internal sealed record ResolvedFoundryToolboxMcpApprovalPolicy(
    FoundryToolboxMcpGlobalApprovalMode? Global,
    ResolvedFoundryToolboxMcpApprovalFilter? Always,
    ResolvedFoundryToolboxMcpApprovalFilter? Never)
{
    public static ResolvedFoundryToolboxMcpApprovalPolicy? Create(
        FoundryToolboxMcpApprovalPolicy? policy)
    {
        if (policy is null)
        {
            return null;
        }
 
        var always = ResolvedFoundryToolboxMcpApprovalFilter.Create(
            policy.Always,
            nameof(policy.Always));
        var never = ResolvedFoundryToolboxMcpApprovalFilter.Create(
            policy.Never,
            nameof(policy.Never));
 
        if (policy.Global is not null && (always is not null || never is not null))
        {
            throw new ArgumentException(
                "A global MCP approval policy cannot be combined with custom filters.",
                nameof(policy));
        }
 
        if (policy.Global is null && always is null && never is null)
        {
            throw new ArgumentException(
                "An MCP approval policy must specify a global mode or at least one custom filter.",
                nameof(policy));
        }
 
        if (policy.Global is not null &&
            policy.Global is not FoundryToolboxMcpGlobalApprovalMode.Never &&
            policy.Global is not FoundryToolboxMcpGlobalApprovalMode.Always)
        {
            throw new ArgumentOutOfRangeException(
                nameof(policy),
                policy.Global,
                "The global MCP approval mode is not supported.");
        }
 
        var overlap = always?.ToolNames
            .Intersect(never?.ToolNames ?? [], StringComparer.Ordinal)
            .FirstOrDefault();
        if (overlap is not null)
        {
            throw new ArgumentException(
                $"MCP tool '{overlap}' cannot both always and never require approval.",
                nameof(policy));
        }
 
        if (always?.ReadOnly is { } alwaysReadOnly && never?.ReadOnly == alwaysReadOnly)
        {
            throw new ArgumentException(
                $"MCP tools with read_only set to '{alwaysReadOnly.ToString().ToLowerInvariant()}' cannot both always and never require approval.",
                nameof(policy));
        }
 
        return new(policy.Global, always, never);
    }
 
    public void WriteTo(Utf8JsonWriter writer)
    {
        if (Global is { } global)
        {
            writer.WriteStringValue(global switch
            {
                FoundryToolboxMcpGlobalApprovalMode.Never => "never",
                FoundryToolboxMcpGlobalApprovalMode.Always => "always",
                _ => throw new InvalidOperationException($"Unsupported MCP approval mode '{global}'.")
            });
            return;
        }
 
        writer.WriteStartObject();
        Always?.WriteTo(writer, "always");
        Never?.WriteTo(writer, "never");
        writer.WriteEndObject();
    }
}
 
internal sealed record ResolvedFoundryToolboxMcpApprovalFilter(
    IReadOnlyList<string> ToolNames,
    bool? ReadOnly)
{
    public static ResolvedFoundryToolboxMcpApprovalFilter? Create(
        FoundryToolboxMcpApprovalFilter? filter,
        string parameterName)
    {
        if (filter is null)
        {
            return null;
        }
 
        var toolNames = (filter.ToolNames ?? [])
            .Select(name =>
            {
                ArgumentException.ThrowIfNullOrWhiteSpace(name, parameterName);
                return name;
            })
            .Distinct(StringComparer.Ordinal)
            .Order(StringComparer.Ordinal)
            .ToArray();
 
        if (toolNames.Length == 0 && filter.ReadOnly is null)
        {
            throw new ArgumentException(
                "An MCP approval filter must specify at least one tool name or a read-only value.",
                parameterName);
        }
 
        return new(toolNames, filter.ReadOnly);
    }
 
    public void WriteTo(Utf8JsonWriter writer, string propertyName)
    {
        writer.WriteStartObject(propertyName);
        if (ToolNames.Count > 0)
        {
            writer.WriteStartArray("tool_names");
            foreach (var toolName in ToolNames)
            {
                writer.WriteStringValue(toolName);
            }
            writer.WriteEndArray();
        }
 
        if (ReadOnly is { } readOnly)
        {
            writer.WriteBoolean("read_only", readOnly);
        }
 
        writer.WriteEndObject();
    }
}
 
/// <summary>
/// Describes an Azure AI Search tool in a Microsoft Foundry Toolbox.
/// </summary>
internal sealed class FoundryToolboxAzureAISearchToolDefinition : FoundryToolboxToolDefinition
{
    internal FoundryToolboxAzureAISearchToolDefinition(
        string name,
        AzureSearchResource searchResource,
        AzureCognitiveServicesProjectConnectionResource connection,
        string indexName,
        string? description)
        : base(name)
    {
        ArgumentNullException.ThrowIfNull(searchResource);
        ArgumentNullException.ThrowIfNull(connection);
 
        SearchResource = searchResource;
        Connection = connection;
        IndexName = indexName;
        Description = description;
    }
 
    /// <summary>
    /// Gets the Azure AI Search resource backing this tool.
    /// </summary>
    public AzureSearchResource SearchResource { get; }
 
    /// <summary>
    /// Gets the Foundry project connection resource used by the tool.
    /// </summary>
    public AzureCognitiveServicesProjectConnectionResource Connection { get; }
 
    /// <summary>
    /// Gets the Azure AI Search index name.
    /// </summary>
    public string IndexName { get; }
 
    public string? Description { get; }
 
    internal override async ValueTask<ResolvedFoundryToolboxTool> ResolveAsync(CancellationToken cancellationToken)
    {
        // The Foundry project connection's "id" bicep output is only populated after provisioning,
        // so this resolves to a real value only at deploy time. Matches AzureAISearchToolResource.
        var connectionIdRef = new BicepOutputReference("id", Connection);
        var connectionId = await connectionIdRef.GetValueAsync(cancellationToken).ConfigureAwait(false);
        if (string.IsNullOrEmpty(connectionId))
        {
            throw new InvalidOperationException(
                $"Failed to resolve connection ID for Azure AI Search tool '{Name}'. " +
                "The Foundry project connection may not have been provisioned correctly.");
        }
 
        var index = new AzureAISearchToolIndex
        {
            ProjectConnectionId = connectionId,
            IndexName = IndexName
        };
        var options = new AzureAISearchToolOptions([index]);
        var unnamedTool = new AzureAISearchTool(options);
        var unnamedJson = ModelReaderWriter.Write(
            unnamedTool,
            ModelReaderWriterOptions.Json,
            AzureAIProjectsAgentsContext.Default);
        using var unnamedDocument = JsonDocument.Parse(unnamedJson);
        using var stream = new MemoryStream();
        using (var writer = new Utf8JsonWriter(stream))
        {
            writer.WriteStartObject();
            foreach (var property in unnamedDocument.RootElement.EnumerateObject())
            {
                property.WriteTo(writer);
            }
            writer.WriteString("name", Name);
            if (Description is not null)
            {
                writer.WriteString("description", Description);
            }
            writer.WriteEndObject();
        }
 
        var json = BinaryData.FromBytes(stream.ToArray());
        var tool = ModelReaderWriter.Read<ProjectsAgentTool>(
            json,
            ModelReaderWriterOptions.Json,
            AzureAIProjectsAgentsContext.Default)!;
 
        return new ResolvedFoundryToolboxTool(Name, tool, json.ToString());
    }
}