File: ToolboxTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Foundry.Tests\Aspire.Hosting.Foundry.Tests.csproj (Aspire.Hosting.Foundry.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIRECOMPUTE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable ASPIREPIPELINES001 // Pipelines APIs are experimental.
#pragma warning disable ASPIREAZURE001 // AzureEnvironmentResource is experimental.
 
using System.ClientModel.Primitives;
using System.Net;
using System.Text;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Azure;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
using Azure.AI.Projects.Agents;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Hosting.Foundry.Tests;
 
public class ToolboxTests
{
    [Fact]
    public void AddToolbox_CreatesProjectChildResource()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
 
        var toolbox = project.AddToolbox("field-tools", t => t.Version = "7");
 
        Assert.Equal("field-tools", toolbox.Resource.Name);
        Assert.Equal("7", toolbox.Resource.Version);
        Assert.Same(project.Resource, toolbox.Resource.Parent);
        Assert.IsNotAssignableFrom<IResourceWithParent>(toolbox.Resource);
        var parentRelationship = Assert.Single(
            toolbox.Resource.Annotations.OfType<ResourceRelationshipAnnotation>());
        Assert.Equal("Parent", parentRelationship.Type);
        Assert.Same(project.Resource, parentRelationship.Resource);
 
#pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates.
        var consumerRole = Assert.Single(
            toolbox.Resource.Annotations.OfType<ReferenceRoleAssignmentAnnotation>());
        Assert.Same(project.Resource, consumerRole.Target);
        var role = Assert.Single(consumerRole.Roles);
        Assert.Equal(FoundryResource.FoundryUserRoleDefinitionId, role.Id, ignoreCase: true);
#pragma warning restore ASPIREAZURE003
    }
 
    [Fact]
    public void RunAsExisting_UsesExistingToolboxOnlyInRunMode()
    {
        using var runBuilder = TestDistributedApplicationBuilder.Create();
        var runToolbox = runBuilder.AddFoundry("run-account")
            .AddProject("run-project")
            .AddToolbox("run-tools")
            .RunAsExisting();
 
        using var publishBuilder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var publishToolbox = publishBuilder.AddFoundry("publish-account")
            .AddProject("publish-project")
            .AddToolbox("publish-tools")
            .RunAsExisting();
 
        Assert.True(runToolbox.Resource.IsExisting);
        Assert.False(publishToolbox.Resource.IsExisting);
    }
 
    [Fact]
    public void PublishAsExisting_UsesExistingToolboxOnlyInPublishMode()
    {
        using var runBuilder = TestDistributedApplicationBuilder.Create();
        var runToolbox = runBuilder.AddFoundry("run-account")
            .AddProject("run-project")
            .AddToolbox("run-tools")
            .PublishAsExisting();
 
        using var publishBuilder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var publishToolbox = publishBuilder.AddFoundry("publish-account")
            .AddProject("publish-project")
            .AddToolbox("publish-tools")
            .PublishAsExisting();
 
        Assert.False(runToolbox.Resource.IsExisting);
        Assert.True(publishToolbox.Resource.IsExisting);
    }
 
    [Theory]
    [InlineData(DistributedApplicationOperation.Run)]
    [InlineData(DistributedApplicationOperation.Publish)]
    public void AsExisting_UsesExistingToolboxInBothModes(DistributedApplicationOperation operation)
    {
        using var builder = TestDistributedApplicationBuilder.Create(operation);
        var toolbox = builder.AddFoundry("account")
            .AddProject("project")
            .AddToolbox("field-tools")
            .AsExisting();
 
        Assert.True(toolbox.Resource.IsExisting);
        Assert.Empty(toolbox.Resource.Tools);
    }
 
    [Fact]
    public void AsExisting_AfterAISearchTool_RemovesToolAndConnection()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var project = builder.AddFoundry("account")
            .AddProject("project");
        var search = builder.AddAzureSearch("search");
        var toolbox = project.AddToolbox("field-tools");
        var resourceCountWithoutConnection = builder.Resources.Count;
        toolbox.WithAISearchTool("knowledge-base", search, "docs");
        Assert.Equal(resourceCountWithoutConnection + 1, builder.Resources.Count);
 
        toolbox.AsExisting();
 
        Assert.Empty(toolbox.Resource.Tools);
        Assert.Equal(resourceCountWithoutConnection, builder.Resources.Count);
    }
 
    [Fact]
    public void AsExisting_BeforeAISearchTool_DoesNotAddToolOrConnection()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var project = builder.AddFoundry("account")
            .AddProject("project");
        var search = builder.AddAzureSearch("search");
        var toolbox = project.AddToolbox("field-tools")
            .AsExisting();
        var resourceCount = builder.Resources.Count;
 
        toolbox.WithAISearchTool("knowledge-base", search, "docs");
 
        Assert.Empty(toolbox.Resource.Tools);
        Assert.Equal(resourceCount, builder.Resources.Count);
    }
 
    [Fact]
    public void WithToolMethods_AddToolDefinitions()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
        var search = builder.AddAzureSearch("search");
 
        var toolbox = project.AddToolbox("field-tools")
            .WithDescription("Tools for field technicians.")
            .WithWebSearchTool()
            .WithMcpTool("inventory", "https://inventory.example.com/mcp")
            .WithAISearchTool(
                "knowledge-base",
                search,
                "docs",
                "Search the internal knowledge base.");
 
        Assert.Collection(
            toolbox.Resource.Tools,
            tool =>
            {
                var webSearch = Assert.IsType<FoundryToolboxWebSearchToolDefinition>(tool);
                Assert.Equal("web-search", webSearch.Name);
            },
            tool =>
            {
                var mcp = Assert.IsType<FoundryToolboxMcpToolDefinition>(tool);
                Assert.Equal("inventory", mcp.Name);
                Assert.Equal("https://inventory.example.com/mcp", mcp.EndpointExpression.ValueExpression);
            },
            tool =>
            {
                var aiSearch = Assert.IsType<FoundryToolboxAzureAISearchToolDefinition>(tool);
                Assert.Equal("knowledge-base", aiSearch.Name);
                Assert.Same(search.Resource, aiSearch.SearchResource);
                Assert.Equal("docs", aiSearch.IndexName);
                Assert.Equal("Search the internal knowledge base.", aiSearch.Description);
                Assert.NotNull(aiSearch.Connection);
            });
    }
 
    [Fact]
    public async Task WithReference_InjectsToolboxConnectionProperties()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
        var toolbox = project.AddToolbox("field-tools", t => t.Version = "7");
 
        var pyapp = builder.AddPythonApp("app", "./app.py", "main:app")
            .WithReference(toolbox);
 
        builder.Build();
        var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            pyapp.Resource, DistributedApplicationOperation.Publish, TestServiceProvider.Instance);
 
        Assert.Contains(envVars, kvp =>
            kvp.Key == "FIELD_TOOLS_NAME"
            && kvp.Value == "field-tools");
        Assert.Contains(envVars, kvp =>
            kvp.Key == "FIELD_TOOLS_PROJECTENDPOINT"
            && kvp.Value == "{my-project.outputs.endpoint}");
        Assert.Contains(envVars, kvp =>
            kvp.Key == "FIELD_TOOLS_URI"
            && kvp.Value == "{my-project.outputs.endpoint}/toolboxes/field-tools/versions/7/mcp?api-version=v1");
        Assert.Contains(envVars, kvp =>
            kvp.Key == "FIELD_TOOLS_FOUNDRYFEATURES"
            && kvp.Value == "Toolboxes=V1Preview");
        Assert.Contains(envVars, kvp =>
            kvp.Key == "FIELD_TOOLS_AUTHORIZATIONSCOPE"
            && kvp.Value == "https://ai.azure.com/.default");
        Assert.Contains(envVars, kvp =>
            kvp.Key == "ConnectionStrings__field-tools"
            && kvp.Value == "{field-tools.connectionString}");
    }
 
    [Fact]
    public void GetVersionUriExpression_UsesReconciledVersionInsteadOfConsumerPin()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var toolbox = builder.AddFoundry("account")
            .AddProject("my-project")
            .AddToolbox("field-tools", options => options.Version = "7");
 
        var expression = toolbox.Resource.GetVersionUriExpression("9");
 
        Assert.Equal(
            "{my-project.outputs.endpoint}/toolboxes/field-tools/versions/9/mcp?api-version=v1",
            expression.ValueExpression);
        Assert.Contains(
            "/versions/7/",
            toolbox.Resource.UriExpression.ValueExpression,
            StringComparison.Ordinal);
    }
 
    [Fact]
    public async Task AsHostedAgent_ResolvesToolboxConnectionString()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
        var toolbox = project.AddToolbox("field-tools", t => t.Version = "7");
 
        var agent = builder.AddPythonApp("agent", "./app.py", "main:app")
            .WithReference(toolbox)
            .AsHostedAgent(project);
 
        using var app = builder.Build();
        var hostedAgent = Assert.Single(builder.Resources.OfType<AzureHostedAgentResource>());
 
        // Seed the Bicep outputs that the AzureCognitiveServicesProjectResource exposes via
        // GetConnectionProperties(): the resolution path walks every env var callback on the
        // hosted agent's target resource, so any project output reachable through a `WithReference`
        // chain must be resolvable for the test to focus on the toolbox connection string assertion.
        project.Resource.Outputs["endpoint"] = "https://project.example.com";
        project.Resource.Outputs["APPLICATION_INSIGHTS_CONNECTION_STRING"] = "InstrumentationKey=test;IngestionEndpoint=https://test.example.com/";
 
        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        var envVars = await AzureHostedAgentResource.GetResolvedEnvironmentVariablesAsync(
            builder.ExecutionContext,
            hostedAgent,
            agent.Resource,
            NullLogger<ToolboxTests>.Instance,
            cts.Token);
 
        Assert.Equal("https://project.example.com/toolboxes/field-tools/versions/7/mcp?api-version=v1", envVars["ConnectionStrings__field-tools"]);
    }
 
    [Fact]
    public async Task AddToolbox_RegistersPublishModeDeployStep()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
        var toolbox = project.AddToolbox("field-tools");
 
        using var app = builder.Build();
 
        var annotation = Assert.Single(toolbox.Resource.Annotations.OfType<PipelineStepAnnotation>());
 
        var ctx = new PipelineStepFactoryContext
        {
            PipelineContext = CreatePipelineContext(app, DistributedApplicationOperation.Publish),
            Resource = toolbox.Resource
        };
 
        var steps = (await annotation.CreateStepsAsync(ctx)).ToList();
 
        // In publish mode only the deploy step is registered (no before-start hook).
        var step = Assert.Single(steps);
        Assert.Equal("deploy-field-tools", step.Name);
        Assert.Contains(WellKnownPipelineTags.DeployCompute, step.Tags);
        Assert.Contains(WellKnownPipelineSteps.Deploy, step.RequiredBySteps);
        Assert.Contains(WellKnownPipelineSteps.DeployPrereq, step.DependsOnSteps);
        Assert.Contains(AzureEnvironmentResource.ProvisionInfrastructureStepName, step.DependsOnSteps);
        Assert.Same(toolbox.Resource, step.Resource);
    }
 
    [Fact]
    public async Task AddToolbox_RegistersRunModeBeforeStartStep()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
        var toolbox = project.AddToolbox("field-tools");
 
        using var app = builder.Build();
 
        var annotation = Assert.Single(toolbox.Resource.Annotations.OfType<PipelineStepAnnotation>());
 
        var ctx = new PipelineStepFactoryContext
        {
            PipelineContext = CreatePipelineContext(app, DistributedApplicationOperation.Run),
            Resource = toolbox.Resource
        };
 
        var steps = (await annotation.CreateStepsAsync(ctx)).ToList();
 
        Assert.Equal(2, steps.Count);
 
        var beforeStart = Assert.Single(steps, s => s.Name == "deploy-field-tools-before-start");
        Assert.Contains("before-start", beforeStart.RequiredBySteps);
        Assert.Contains(AzureEnvironmentResource.PrepareResourcesStepName, beforeStart.DependsOnSteps);
        Assert.Same(toolbox.Resource, beforeStart.Resource);
 
        var deploy = Assert.Single(steps, s => s.Name == "deploy-field-tools");
        Assert.Contains(WellKnownPipelineTags.DeployCompute, deploy.Tags);
    }
 
    [Fact]
    public async Task WebSearchToolDefinition_ConvertsToProjectsAgentTool()
    {
        var tool = new FoundryToolboxWebSearchToolDefinition("web-search");
 
        var projectTool = (await tool.ResolveAsync(CancellationToken.None)).Tool;
 
        Assert.NotNull(projectTool);
        var json = ModelReaderWriter.Write(
            projectTool,
            ModelReaderWriterOptions.Json,
            AzureAIProjectsAgentsContext.Default);
        Assert.Equal("""{"type":"web_search","name":"web-search"}""", json.ToString());
    }
 
    [Fact]
    public async Task WebSearchToolDefinition_IncludesDescriptionWhenConfigured()
    {
        var tool = new FoundryToolboxWebSearchToolDefinition(
            "web-search",
            "Search the public web.");
 
        var projectTool = (await tool.ResolveAsync(CancellationToken.None)).Tool;
 
        var json = ModelReaderWriter.Write(
            projectTool,
            ModelReaderWriterOptions.Json,
            AzureAIProjectsAgentsContext.Default);
        Assert.Equal(
            """{"type":"web_search","name":"web-search","description":"Search the public web."}""",
            json.ToString());
    }
 
    [Fact]
    public async Task AzureAISearchToolDefinition_ConvertsToAzureAISearchTool()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
        var search = builder.AddAzureSearch("search");
 
        var toolbox = project.AddToolbox("field-tools")
            .WithAISearchTool(
                "knowledge-base",
                search,
                "docs",
                "Search the internal knowledge base.");
 
        // Pre-seed the connection's bicep output so the tool conversion can resolve it without
        // running real provisioning.
        var def = Assert.IsType<FoundryToolboxAzureAISearchToolDefinition>(toolbox.Resource.Tools[0]);
        def.Connection.Outputs["id"] = "/subscriptions/sub/resourceGroups/rg/connections/search";
 
        var projectTool = (await def.ResolveAsync(CancellationToken.None)).Tool;
 
        var aiSearch = Assert.IsType<AzureAISearchTool>(projectTool);
        var index = Assert.Single(aiSearch.Options.Indexes);
        Assert.Equal("/subscriptions/sub/resourceGroups/rg/connections/search", index.ProjectConnectionId);
        Assert.Equal("docs", index.IndexName);
        var json = ModelReaderWriter.Write(
            projectTool,
            ModelReaderWriterOptions.Json,
            AzureAIProjectsAgentsContext.Default);
        Assert.Contains(
            "\"name\":\"knowledge-base\",\"description\":\"Search the internal knowledge base.\"",
            json.ToString(),
            StringComparison.Ordinal);
    }
 
    [Fact]
    public async Task ReadinessProbe_RetriesAndFollowsToolsListPagination()
    {
        var initialize = new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = new StringContent(
                """{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-03-26"}}""",
                Encoding.UTF8,
                "application/json")
        };
        initialize.Headers.Add("Mcp-Session-Id", "session-1");
        using var handler = new SequenceHttpMessageHandler(
            initialize,
            new HttpResponseMessage(HttpStatusCode.Accepted),
            new HttpResponseMessage(HttpStatusCode.InternalServerError),
            CreateJsonResponse("""{"jsonrpc":"2.0","id":3,"result":{"tools":[{"name":"other"}],"nextCursor":"page-2"}}"""),
            CreateJsonResponse("""{"jsonrpc":"2.0","id":4,"result":{"tools":[{"name":"knowledge-base"}]}}"""));
        using var client = new HttpClient(handler);
 
        var tools = await new FoundryToolboxReadinessProbe(
            client,
            timeout: TimeSpan.FromSeconds(1),
            retryDelay: TimeSpan.Zero)
            .WaitForToolsAsync(
                new Uri("https://project.example.com/toolboxes/field-tools/mcp?api-version=v1"),
                "token",
                ["knowledge-base"],
                requiredMcpServerLabels: [],
                CancellationToken.None);
 
        Assert.Equal(2, tools.Count);
        Assert.Contains("knowledge-base", tools);
        Assert.Collection(
            handler.Requests,
            request =>
            {
                Assert.Contains("\"method\":\"initialize\"", request.Content, StringComparison.Ordinal);
                Assert.Null(request.SessionId);
                Assert.Null(request.ProtocolVersion);
            },
            request =>
            {
                Assert.Contains("\"method\":\"notifications/initialized\"", request.Content, StringComparison.Ordinal);
                Assert.Equal("session-1", request.SessionId);
                Assert.Equal("2025-03-26", request.ProtocolVersion);
            },
            request => Assert.Equal("""{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}""", request.Content),
            request => Assert.Equal("""{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{}}""", request.Content),
            request => Assert.Equal("""{"jsonrpc":"2.0","id":4,"method":"tools/list","params":{"cursor":"page-2"}}""", request.Content));
    }
 
    [Fact]
    public async Task ReadinessProbe_WaitsForEveryConfiguredMcpServer()
    {
        var initialize = CreateJsonResponse(
            """{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-03-26"}}""");
        using var handler = new SequenceHttpMessageHandler(
            initialize,
            new HttpResponseMessage(HttpStatusCode.Accepted),
            CreateJsonResponse("""{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"web-search"}]}}"""),
            CreateJsonResponse(
               """{"jsonrpc":"2.0","id":3,"result":{"tools":[{"name":"web-search"},{"name":"inventory.lookup"}]}}"""));
        using var client = new HttpClient(handler);
 
        var tools = await new FoundryToolboxReadinessProbe(
            client,
            timeout: TimeSpan.FromSeconds(1),
            retryDelay: TimeSpan.Zero)
            .WaitForToolsAsync(
               new Uri("https://project.example.com/toolboxes/field-tools/mcp?api-version=v1"),
               "token",
               ["web-search"],
               ["inventory"],
               CancellationToken.None);
 
        Assert.Equal(["inventory.lookup", "web-search"], tools.Order(StringComparer.Ordinal));
        Assert.Equal(4, handler.Requests.Count);
    }
 
    [Fact]
    public async Task McpToolDefinition_ConvertsWithLiteralEndpoint()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
 
        var toolbox = project.AddToolbox("field-tools")
            .WithMcpTool("inventory", "https://inventory.example.com/mcp");
 
        var def = Assert.IsType<FoundryToolboxMcpToolDefinition>(toolbox.Resource.Tools[0]);
 
        var projectTool = (await def.ResolveAsync(CancellationToken.None)).Tool;
 
        var json = ModelReaderWriter.Write(
            projectTool,
            ModelReaderWriterOptions.Json,
            AzureAIProjectsAgentsContext.Default);
        Assert.Equal(
            """{"type":"mcp","server_label":"inventory","server_url":"https://inventory.example.com/mcp"}""",
            json.ToString());
    }
 
    [Fact]
    public async Task McpToolDefinition_IncludesServerMetadataAndGlobalApproval()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
        var toolbox = project.AddToolbox("field-tools")
            .WithMcpTool(
                "inventory",
                "https://inventory.example.com/mcp",
                new FoundryToolboxMcpToolOptions
                {
                    ServerLabel = "inventory-server",
                    ServerDescription = "Inventory MCP server.",
                    ApprovalPolicy = new()
                    {
                        Global = FoundryToolboxMcpGlobalApprovalMode.Always
                    }
                });
        var definition = Assert.IsType<FoundryToolboxMcpToolDefinition>(
            Assert.Single(toolbox.Resource.Tools));
 
        var projectTool = (await definition.ResolveAsync(CancellationToken.None)).Tool;
 
        var json = ModelReaderWriter.Write(
            projectTool,
            ModelReaderWriterOptions.Json,
            AzureAIProjectsAgentsContext.Default);
        Assert.Equal(
            """{"type":"mcp","server_label":"inventory-server","server_url":"https://inventory.example.com/mcp","server_description":"Inventory MCP server.","require_approval":"always"}""",
            json.ToString());
    }
 
    [Fact]
    public async Task McpToolDefinition_IncludesCanonicalCustomApproval()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
        var toolbox = project.AddToolbox("field-tools")
            .WithMcpTool(
                "inventory",
                "https://inventory.example.com/mcp",
                new FoundryToolboxMcpToolOptions
                {
                    ApprovalPolicy = new()
                    {
                        Always = new()
                        {
                            ToolNames = ["write", "delete", "write"],
                            ReadOnly = false
                        },
                        Never = new()
                        {
                            ToolNames = ["read"],
                            ReadOnly = true
                        }
                    }
                });
        var definition = Assert.IsType<FoundryToolboxMcpToolDefinition>(
            Assert.Single(toolbox.Resource.Tools));
 
        var projectTool = (await definition.ResolveAsync(CancellationToken.None)).Tool;
 
        var json = ModelReaderWriter.Write(
            projectTool,
            ModelReaderWriterOptions.Json,
            AzureAIProjectsAgentsContext.Default);
        Assert.Equal(
            """{"type":"mcp","server_label":"inventory","server_url":"https://inventory.example.com/mcp","require_approval":{"always":{"tool_names":["delete","write"],"read_only":false},"never":{"tool_names":["read"],"read_only":true}}}""",
            json.ToString());
    }
 
    [Fact]
    public void WithMcpTool_RejectsMixedGlobalAndCustomApproval()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
 
        var exception = Assert.Throws<ArgumentException>(
            () => project.AddToolbox("field-tools")
                .WithMcpTool(
                    "inventory",
                    "https://inventory.example.com/mcp",
                    new FoundryToolboxMcpToolOptions
                    {
                        ApprovalPolicy = new()
                        {
                            Global = FoundryToolboxMcpGlobalApprovalMode.Always,
                            Never = new()
                            {
                                ToolNames = ["read"]
                            }
                        }
                    }));
 
        Assert.Contains(
            "cannot be combined with custom filters",
            exception.Message,
            StringComparison.Ordinal);
    }
 
    [Fact]
    public void WithMcpTool_RejectsEmptyCustomApprovalFilter()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
 
        var exception = Assert.Throws<ArgumentException>(
            () => project.AddToolbox("field-tools")
                .WithMcpTool(
                    "inventory",
                    "https://inventory.example.com/mcp",
                    new FoundryToolboxMcpToolOptions
                    {
                        ApprovalPolicy = new()
                        {
                            Always = new()
                        }
                    }));
 
        Assert.Contains(
            "must specify at least one tool name or a read-only value",
            exception.Message,
            StringComparison.Ordinal);
    }
 
    [Fact]
    public async Task McpToolDefinition_ThrowsWhenEndpointUnresolved()
    {
        // Construct an MCP tool definition directly with a reference expression that resolves to
        // empty (a parameter callback returning string.Empty). The public WithMcpTool overloads
        // both reject null/empty literal strings up-front, so we go through the internal ctor here.
        using var builder = TestDistributedApplicationBuilder.Create();
        var empty = builder.AddParameter("empty-endpoint", () => string.Empty);
 
        var def = new FoundryToolboxMcpToolDefinition(
            "inventory",
            ReferenceExpression.Create($"{empty.Resource}"));
 
        await Assert.ThrowsAsync<InvalidOperationException>(
            async () => await def.ResolveAsync(CancellationToken.None));
    }
 
    [Fact]
    public async Task McpToolDefinition_ThrowsWhenEndpointIsNotHttps()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var def = new FoundryToolboxMcpToolDefinition(
            "inventory",
            ReferenceExpression.Create($"http://inventory.example.com/mcp"));
 
        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            async () => await def.ResolveAsync(CancellationToken.None));
        Assert.Contains("Foundry-reachable absolute HTTPS endpoint", ex.Message, StringComparison.Ordinal);
    }
 
    [Fact]
    public async Task McpToolDefinition_ThrowsWhenEndpointIsLoopback()
    {
        var def = new FoundryToolboxMcpToolDefinition(
            "inventory",
            ReferenceExpression.Create($"https://localhost:7443/mcp"));
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(
            async () => await def.ResolveAsync(CancellationToken.None));
 
        Assert.Contains("Foundry-reachable", exception.Message, StringComparison.Ordinal);
    }
 
    [Theory]
    [InlineData("https://inventory.localhost:7443/mcp")]
    [InlineData("https://user:password@inventory.example.com/mcp")]
    public async Task McpToolDefinition_ThrowsWhenEndpointIsNotPubliclyReachable(string endpoint)
    {
        var def = new FoundryToolboxMcpToolDefinition(
            "inventory",
            ReferenceExpression.Create($"{endpoint}"));
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(
            async () => await def.ResolveAsync(CancellationToken.None));
 
        Assert.Contains("Foundry-reachable", exception.Message, StringComparison.Ordinal);
    }
 
    [Fact]
    public void WithMcpTool_ThrowsImmediatelyWhenLiteralEndpointIsNotHttps()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
 
        var exception = Assert.Throws<ArgumentException>(
            () => project.AddToolbox("field-tools")
                .WithMcpTool("inventory", "http://inventory.example.com/mcp"));
 
        Assert.Equal("endpoint", exception.ParamName);
    }
 
    [Fact]
    public void WithMcpTool_ThrowsImmediatelyWhenLiteralEndpointIsLoopback()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
 
        var exception = Assert.Throws<ArgumentException>(
            () => project.AddToolbox("field-tools")
                .WithMcpTool("inventory", "https://localhost:7443/mcp"));
 
        Assert.Equal("endpoint", exception.ParamName);
    }
 
    [Theory]
    [InlineData("https://inventory.localhost:7443/mcp")]
    [InlineData("https://user:password@inventory.example.com/mcp")]
    public void WithMcpTool_ThrowsImmediatelyWhenLiteralEndpointIsNotPubliclyReachable(string endpoint)
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
 
        var exception = Assert.Throws<ArgumentException>(
            () => project.AddToolbox("field-tools")
                .WithMcpTool("inventory", endpoint));
 
        Assert.Equal("endpoint", exception.ParamName);
    }
 
    [Fact]
    public void WithAISearchTool_UsesDeterministicConnectionName()
    {
        using var firstBuilder = TestDistributedApplicationBuilder.Create();
        var firstProject = firstBuilder.AddFoundry("account")
            .AddProject("my-project");
        var firstSearch = firstBuilder.AddAzureSearch("search");
        var firstToolbox = firstProject.AddToolbox("field-tools")
            .WithAISearchTool("knowledge-base", firstSearch, "docs");
 
        using var secondBuilder = TestDistributedApplicationBuilder.Create();
        var secondProject = secondBuilder.AddFoundry("account")
            .AddProject("my-project");
        var secondSearch = secondBuilder.AddAzureSearch("search");
        var secondToolbox = secondProject.AddToolbox("field-tools")
            .WithAISearchTool("knowledge-base", secondSearch, "docs");
 
        var firstDefinition = Assert.IsType<FoundryToolboxAzureAISearchToolDefinition>(
            Assert.Single(firstToolbox.Resource.Tools));
        var secondDefinition = Assert.IsType<FoundryToolboxAzureAISearchToolDefinition>(
            Assert.Single(secondToolbox.Resource.Tools));
 
        Assert.Equal(firstDefinition.Connection.Name, secondDefinition.Connection.Name);
    }
 
    [Fact]
    public void WithAISearchTool_EmitsSearchRoleAssignmentsForProjectIdentity()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
        var search = builder.AddAzureSearch("search");
        var toolbox = project.AddToolbox("field-tools")
            .WithAISearchTool("knowledge-base", search, "docs");
        var definition = Assert.IsType<FoundryToolboxAzureAISearchToolDefinition>(
            Assert.Single(toolbox.Resource.Tools));
 
        var bicep = definition.Connection.GetBicepTemplateString();
 
        Assert.Contains("8ebe5a00-799e-43f5-93ac-243d3dce84a7", bicep, StringComparison.Ordinal);
        Assert.Contains("7ca78c08-252a-4471-8644-bb5ff32d4ba0", bicep, StringComparison.Ordinal);
        Assert.Contains("principalId", bicep, StringComparison.Ordinal);
    }
 
    [Fact]
    public void WithAISearchTool_RejectsEmptyIndexName()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
        var search = builder.AddAzureSearch("search");
 
        var exception = Assert.Throws<ArgumentException>(
            () => project.AddToolbox("field-tools")
                .WithAISearchTool("knowledge-base", search, string.Empty));
 
        Assert.Equal("indexName", exception.ParamName);
    }
 
    [Fact]
    public async Task AddToolbox_McpTool_PublishConfigurationAnnotation_WiresDependencyOnReferencedCompute()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
        var mcp = builder.AddContainer("mcp", "ghcr.io/example/mcp")
            .WithHttpEndpoint(targetPort: 8080, name: "http");
 
        var toolbox = project.AddToolbox("field-tools")
            .WithMcpTool("inventory", mcp.GetEndpoint("http"));
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        var replacement = new ContainerResource(mcp.Resource.Name);
        model.Resources.Remove(mcp.Resource);
        model.Resources.Add(replacement);
 
        // Materialize the toolbox's own deploy-compute step via its PipelineStepAnnotation, then
        // fabricate a stand-in deploy-compute step for the referenced container - in a real publish
        // run this would come from the AzureContainerApp pipeline. The PipelineConfigurationAnnotation
        // we're testing wires DependsOnSteps across these two via tag-based lookup, independent of who
        // produced them.
        var toolboxStepAnnotation = Assert.Single(toolbox.Resource.Annotations.OfType<PipelineStepAnnotation>());
        var toolboxSteps = (await toolboxStepAnnotation.CreateStepsAsync(new PipelineStepFactoryContext
        {
            PipelineContext = CreatePipelineContext(app, DistributedApplicationOperation.Publish),
            Resource = toolbox.Resource
        })).ToList();
        var toolboxDeploy = Assert.Single(toolboxSteps, s => s.Name == "deploy-field-tools");
 
        var containerDeploy = new PipelineStep
        {
            Name = "deploy-mcp",
            Action = _ => Task.CompletedTask,
            Resource = replacement,
            Tags = { WellKnownPipelineTags.DeployCompute },
        };
 
        var configCtx = new PipelineConfigurationContext
        {
            Services = app.Services,
            Model = model,
            Steps = new[] { toolboxDeploy, containerDeploy }
        };
 
        var configAnnotation = Assert.Single(toolbox.Resource.Annotations.OfType<PipelineConfigurationAnnotation>());
        await configAnnotation.Callback(configCtx);
 
        Assert.Contains("deploy-mcp", toolboxDeploy.DependsOnSteps);
    }
 
    [Fact]
    public async Task AddToolbox_McpTool_PublishConfigurationAnnotation_LiteralEndpoint_AddsNoDependency()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
 
        var toolbox = project.AddToolbox("field-tools")
            .WithMcpTool("inventory", "https://inventory.example.com/mcp");
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var toolboxStepAnnotation = Assert.Single(toolbox.Resource.Annotations.OfType<PipelineStepAnnotation>());
        var toolboxSteps = (await toolboxStepAnnotation.CreateStepsAsync(new PipelineStepFactoryContext
        {
            PipelineContext = CreatePipelineContext(app, DistributedApplicationOperation.Publish),
            Resource = toolbox.Resource
        })).ToList();
        var toolboxDeploy = Assert.Single(toolboxSteps, s => s.Name == "deploy-field-tools");
 
        var dependsOnBefore = toolboxDeploy.DependsOnSteps.ToArray();
 
        var configCtx = new PipelineConfigurationContext
        {
            Services = app.Services,
            Model = model,
            Steps = new[] { toolboxDeploy }
        };
 
        var configAnnotation = Assert.Single(toolbox.Resource.Annotations.OfType<PipelineConfigurationAnnotation>());
        await configAnnotation.Callback(configCtx);
 
        // A literal-URI MCP tool has no resource references to walk, so the configuration pass
        // should leave the existing dependency list untouched.
        Assert.Equal(dependsOnBefore, toolboxDeploy.DependsOnSteps);
    }
 
    [Fact]
    public async Task AsExisting_PublishConfigurationAnnotation_AddsNoToolDependencies()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var project = builder.AddFoundry("account")
            .AddProject("my-project");
        var mcp = builder.AddContainer("mcp", "ghcr.io/example/mcp")
            .WithHttpEndpoint(targetPort: 8080, name: "http");
        var toolbox = project.AddToolbox("field-tools")
            .WithMcpTool("inventory", mcp.GetEndpoint("http"))
            .AsExisting();
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        var toolboxStepAnnotation = Assert.Single(toolbox.Resource.Annotations.OfType<PipelineStepAnnotation>());
        var toolboxSteps = (await toolboxStepAnnotation.CreateStepsAsync(new PipelineStepFactoryContext
        {
            PipelineContext = CreatePipelineContext(app, DistributedApplicationOperation.Publish),
            Resource = toolbox.Resource
        })).ToList();
        var toolboxDeploy = Assert.Single(toolboxSteps, step => step.Name == "deploy-field-tools");
        var mcpDeploy = new PipelineStep
        {
            Name = "deploy-mcp",
            Action = _ => Task.CompletedTask,
            Resource = mcp.Resource,
            Tags = { WellKnownPipelineTags.DeployCompute },
        };
        var dependenciesBefore = toolboxDeploy.DependsOnSteps.ToArray();
 
        var configurationAnnotation = Assert.Single(
            toolbox.Resource.Annotations.OfType<PipelineConfigurationAnnotation>());
        await configurationAnnotation.Callback(new PipelineConfigurationContext
        {
            Services = app.Services,
            Model = model,
            Steps = [toolboxDeploy, mcpDeploy]
        });
 
        Assert.Equal(dependenciesBefore, toolboxDeploy.DependsOnSteps);
    }
 
    [Fact]
    public async Task WaitForMcpResourceAsync_ThrowsWhenDependencyFailsToStart()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var mcp = builder.AddContainer("mcp", "ghcr.io/example/mcp");
        using var app = builder.Build();
        var notifications = app.Services.GetRequiredService<ResourceNotificationService>();
        await notifications.PublishUpdateAsync(mcp.Resource, snapshot => snapshot with
        {
            State = KnownResourceStates.FailedToStart
        });
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(
            () => FoundryToolboxResource.WaitForMcpResourceAsync(
                notifications,
                mcp.Resource,
                CancellationToken.None));
 
        Assert.Contains("terminal state 'FailedToStart'", exception.Message, StringComparison.Ordinal);
    }
 
    private static PipelineContext CreatePipelineContext(DistributedApplication app, DistributedApplicationOperation operation)
    {
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        var execContext = new DistributedApplicationExecutionContext(operation);
        return new PipelineContext(model, execContext, app.Services, NullLogger.Instance, CancellationToken.None);
    }
 
    private static HttpResponseMessage CreateJsonResponse(string json) =>
        new(HttpStatusCode.OK)
        {
            Content = new StringContent(json, Encoding.UTF8, "application/json")
        };
}