diff --git a/playground/FoundryAgentBasic/FoundryAgentBasic.AppHost/AppHost.cs b/playground/FoundryAgentBasic/FoundryAgentBasic.AppHost/AppHost.cs index 5949d2d333c..17843ec1636 100644 --- a/playground/FoundryAgentBasic/FoundryAgentBasic.AppHost/AppHost.cs +++ b/playground/FoundryAgentBasic/FoundryAgentBasic.AppHost/AppHost.cs @@ -7,4 +7,9 @@ project.AddModelDeployment("chat", FoundryModel.OpenAI.Gpt41Mini); +// Add a Foundry Toolbox with a single WebSearch tool. Aspire reconciles the Toolbox on the Foundry +// data plane during local runs and deployments. +project.AddToolbox("field-tools") + .WithWebSearchTool(); + builder.Build().Run(); diff --git a/src/Aspire.Hosting.Foundry/FoundryResource.cs b/src/Aspire.Hosting.Foundry/FoundryResource.cs index 666217be944..ae9468268a0 100644 --- a/src/Aspire.Hosting.Foundry/FoundryResource.cs +++ b/src/Aspire.Hosting.Foundry/FoundryResource.cs @@ -19,6 +19,8 @@ namespace Aspire.Hosting.Foundry; public class FoundryResource(string name, Action configureInfrastructure) : AzureProvisioningResource(name, configureInfrastructure), IResourceWithEndpoints, IResourceWithConnectionString, IAzurePrivateEndpointTarget, IAzureNspAssociationTarget { + internal const string FoundryUserRoleDefinitionId = "53ca6127-db72-4b80-b1b0-d745d6d5456d"; + internal Uri? EmulatorServiceUri { get; set; } private readonly List _deployments = []; diff --git a/src/Aspire.Hosting.Foundry/HostedAgent/AzureHostedAgentResource.cs b/src/Aspire.Hosting.Foundry/HostedAgent/AzureHostedAgentResource.cs index 925ffed94c0..f64a241bc00 100644 --- a/src/Aspire.Hosting.Foundry/HostedAgent/AzureHostedAgentResource.cs +++ b/src/Aspire.Hosting.Foundry/HostedAgent/AzureHostedAgentResource.cs @@ -25,10 +25,6 @@ namespace Aspire.Hosting.Foundry; /// public class AzureHostedAgentResource : Resource, IResourceWithEnvironment { - // The "Azure AI User" built-in role (data-plane access to Foundry agents/inference). Granted to - // the agent's own instance identity below, and to consumers that reference the agent (see - // HostedAgentResourceBuilderExtensions.GrantHostedAgentConsumerRoles). - internal const string AzureAIUserRoleDefinitionId = "53ca6127-db72-4b80-b1b0-d745d6d5456d"; internal const string DefaultResponsesProtocolVersion = "2.0.0"; /// @@ -252,13 +248,13 @@ private async Task AssignFoundryRoleToAgentIdentityAsync( var foundryResourceId = await project.Parent.Id.GetValueAsync(context.CancellationToken).ConfigureAwait(false); if (string.IsNullOrEmpty(foundryResourceId)) { - context.Logger.LogWarning("Could not resolve the Microsoft Foundry resource ID for hosted agent '{Name}'. The agent identity '{PrincipalId}' may need the Cognitive Services User role assigned manually.", Name, principalId); + context.Logger.LogWarning("Could not resolve the Microsoft Foundry resource ID for hosted agent '{Name}'. The agent identity '{PrincipalId}' may need the Foundry User role assigned manually.", Name, principalId); return; } var subscriptionResourceId = provisioningContext.Subscription.Id.ToString(); var roleDefinitionId = new ResourceIdentifier( - $"{subscriptionResourceId}/providers/Microsoft.Authorization/roleDefinitions/{AzureAIUserRoleDefinitionId}"); + $"{subscriptionResourceId}/providers/Microsoft.Authorization/roleDefinitions/{FoundryResource.FoundryUserRoleDefinitionId}"); var assignmentName = StableGuid(principalId, roleDefinitionId.ToString(), foundryResourceId); @@ -278,13 +274,13 @@ await assignments.CreateOrUpdateAsync( content, context.CancellationToken).ConfigureAwait(false); - context.Logger.LogInformation("Assigned Cognitive Services User role to hosted agent '{Name}' identity '{PrincipalId}'.", Name, principalId); + context.Logger.LogInformation("Assigned Foundry User role to hosted agent '{Name}' identity '{PrincipalId}'.", Name, principalId); } catch (RequestFailedException ex) { context.Logger.LogWarning( ex, - "Could not create Cognitive Services User role assignment for hosted agent '{Name}' identity '{PrincipalId}' on Foundry resource '{FoundryResourceId}'. Create the role assignment manually.", + "Could not create Foundry User role assignment for hosted agent '{Name}' identity '{PrincipalId}' on Foundry resource '{FoundryResourceId}'. Create the role assignment manually.", Name, principalId, foundryResourceId); diff --git a/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs index 823e322ed8c..6c026f9a4d3 100644 --- a/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs +++ b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs @@ -533,7 +533,7 @@ private static void ConfigurePublishMode( // Unlike referencing a first-class Azure resource, it does not give the consumer a managed // identity or any RBAC on the Foundry account, so calls to the agent's invocation endpoint // fail with 401/403 at runtime. Stamp a ReferenceRoleAssignmentAnnotation on the agent's - // target so AzureResourcePreparer grants the "Azure AI User" role on the owning Foundry + // target so AzureResourcePreparer grants the "Foundry User" role on the owning Foundry // account to every consumer that references this agent, and provisions the identity that // makes ACA inject AZURE_CLIENT_ID. StampHostedAgentConsumerRoleAnnotation(target, projectResource.Parent); @@ -541,17 +541,17 @@ private static void ConfigurePublishMode( private static void StampHostedAgentConsumerRoleAnnotation(IResourceWithEnvironment target, FoundryResource account) { - // Grant only the "Azure AI User" role required to invoke the hosted agent. We deliberately do + // Grant only the "Foundry User" role required to invoke the hosted agent. We deliberately do // not union the account's default data-plane roles here: // - A consumer that also references the account directly still receives those defaults through // AzureResourcePreparer's normal reference walk (they are preserved when GetAllRoleAssignments // unions per target). // - A consumer that declares explicit role assignments on the account intentionally suppresses // the account defaults; folding them back in here would defeat that suppression. - // So the minimal, least-privilege grant for a pure agent consumer is "Azure AI User" alone. + // So the minimal, least-privilege grant for a pure agent consumer is "Foundry User" alone. var roles = new HashSet { - new(AzureHostedAgentResource.AzureAIUserRoleDefinitionId, "Azure AI User") + new(FoundryResource.FoundryUserRoleDefinitionId, "Foundry User") }; #pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. diff --git a/src/Aspire.Hosting.Foundry/Project/ConnectionBuilderExtensions.cs b/src/Aspire.Hosting.Foundry/Project/ConnectionBuilderExtensions.cs index b32068bf157..eb3390a97c8 100644 --- a/src/Aspire.Hosting.Foundry/Project/ConnectionBuilderExtensions.cs +++ b/src/Aspire.Hosting.Foundry/Project/ConnectionBuilderExtensions.cs @@ -5,6 +5,7 @@ using Aspire.Hosting.Azure; using Aspire.Hosting.Foundry; using Azure.Provisioning; +using Azure.Provisioning.Authorization; using Azure.Provisioning.CognitiveServices; using Azure.Provisioning.Expressions; using Azure.Provisioning.KeyVault; @@ -34,6 +35,13 @@ public static IResourceBuilder this IResourceBuilder builder, [ResourceName] string name, Func configureProperties) + => AddConnection(builder, name, configureProperties, configureAdditionalInfrastructure: null); + + private static IResourceBuilder AddConnection( + this IResourceBuilder builder, + string name, + Func configureProperties, + Action? configureAdditionalInfrastructure) { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(name); @@ -68,6 +76,7 @@ void configureInfrastructure(AzureResourceInfrastructure infrastructure) var keyVaultConn = aspireResource.Parent.KeyVaultConn.AddAsExistingResource(infrastructure); connection.DependsOn.Add(keyVaultConn); } + configureAdditionalInfrastructure?.Invoke(infrastructure, project); infrastructure.Add(new ProvisioningOutput("name", typeof(string)) { Value = connection.Name }); infrastructure.Add(new ProvisioningOutput("id", typeof(string)) { Value = connection.Id }); } @@ -203,25 +212,51 @@ public static IResourceBuilder public static IResourceBuilder AddConnection( this IResourceBuilder builder, AzureSearchResource search) + => builder.AddSearchConnection($"connection-{Guid.NewGuid():N}", search); + + internal static IResourceBuilder AddSearchConnection( + this IResourceBuilder builder, + string name, + AzureSearchResource search) { ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); ArgumentNullException.ThrowIfNull(search); - return builder.AddConnection($"connection-{Guid.NewGuid():N}", (infra) => - { - var searchService = (SearchService)search.AddAsExistingResource(infra); - return new AadAuthTypeConnectionProperties() + return builder.AddConnection( + name, + infra => { - Category = CognitiveServicesConnectionCategory.CognitiveSearch, - Target = BicepFunction.Interpolate($"https://{searchService.Name}.search.windows.net"), - Metadata = + var searchService = (SearchService)search.AddAsExistingResource(infra); + return new AadAuthTypeConnectionProperties() { - { "ApiType", "Azure" }, - { "ResourceId", searchService.Id }, - { "location", searchService.Location } - } - }; - }); + Category = CognitiveServicesConnectionCategory.CognitiveSearch, + Target = BicepFunction.Interpolate($"https://{searchService.Name}.search.windows.net"), + Metadata = + { + { "ApiType", "Azure" }, + { "ResourceId", searchService.Id }, + { "location", searchService.Location } + } + }; + }, + (infra, project) => + { + var searchService = (SearchService)search.AddAsExistingResource(infra); + var projectPrincipalId = builder.Resource.PrincipalId.AsProvisioningParameter(infra); + AddSearchRoleAssignment( + infra, + searchService, + project, + projectPrincipalId, + SearchBuiltInRole.SearchIndexDataContributor); + AddSearchRoleAssignment( + infra, + searchService, + project, + projectPrincipalId, + SearchBuiltInRole.SearchServiceContributor); + }); } /// @@ -232,12 +267,30 @@ public static IResourceBuilder this IResourceBuilder builder, IResourceBuilder search) { - builder.WithRoleAssignments(search, - SearchBuiltInRole.SearchIndexDataReader, - SearchBuiltInRole.SearchServiceContributor); return builder.AddConnection(search.Resource); } + private static void AddSearchRoleAssignment( + AzureResourceInfrastructure infrastructure, + SearchService searchService, + CognitiveServicesProject project, + BicepValue projectPrincipalId, + SearchBuiltInRole role) + { + var roleAssignment = searchService.CreateRoleAssignment( + role, + RoleManagementPrincipalType.ServicePrincipal, + projectPrincipalId); + // Use the same name as ProjectBuilderExtension, which may already have created this + // (scope, principal, role) assignment. Azure rejects an equivalent assignment under + // a different name with RoleAssignmentExists, so both modules must derive the same GUID. + roleAssignment.Name = BicepFunction.CreateGuid( + searchService.Id, + project.Id, + roleAssignment.RoleDefinitionId); + infrastructure.Add(roleAssignment); + } + /// /// Adds a Key Vault connection to the Microsoft Foundry project. /// diff --git a/src/Aspire.Hosting.Foundry/README.md b/src/Aspire.Hosting.Foundry/README.md index c23a1f9a18d..2b9ec0d6c3b 100644 --- a/src/Aspire.Hosting.Foundry/README.md +++ b/src/Aspire.Hosting.Foundry/README.md @@ -88,6 +88,20 @@ The Microsoft Foundry project resource exposes the following connection properti | `ConnectionString` | The connection string, with the format `Endpoint=` | | `ApplicationInsightsConnectionString` | The Application Insights connection string for telemetry | +### Microsoft Foundry Toolbox + +The Toolbox resource exposes the following connection properties: + +| Property Name | Description | +|---------------|-------------| +| `Name` | The Toolbox resource name | +| `ProjectEndpoint` | The parent Microsoft Foundry project endpoint | +| `Uri` | The MCP consumer endpoint, or the version-specific endpoint when `Version` is set | +| `ApiVersion` | The Toolbox data-plane API version | +| `FoundryFeatures` | The required `Foundry-Features` request header value | +| `AuthorizationScope` | The Microsoft Entra authorization scope for Toolbox requests | +| `Version` | The pinned immutable version, when configured | + Aspire exposes each property as an environment variable named `[RESOURCE]_[PROPERTY]`. For instance, the `Uri` property of a resource called `chat` becomes `CHAT_URI`. ## Microsoft Foundry project usage @@ -129,6 +143,129 @@ builder.AddPythonApp("agent", "./app", "main:app") In run mode, the agent runs locally with health check endpoints and OpenTelemetry instrumentation. In publish mode, the agent is deployed as a hosted agent in Microsoft Foundry. +## Toolbox usage + +Toolboxes bundle reusable Foundry tools behind a single MCP endpoint. Aspire creates the first +immutable Toolbox version and promotes new versions only when the configured tools, description, +or metadata change. + +**C#** + +```csharp +var foundry = builder.AddFoundry("foundry"); +var project = foundry.AddProject("my-project"); +var search = builder.AddAzureSearch("search"); + +var toolbox = project.AddToolbox("field-tools") + .WithDescription("Tools for field technicians.") + .WithWebSearchTool("web-search", "Search the public web.") + .WithAISearchTool("knowledge-base", search, "docs", "Search the internal knowledge base.") + .WithMcpTool( + "inventory", + "https://inventory.example.com/mcp", + new FoundryToolboxMcpToolOptions + { + ServerDescription = "Inventory MCP server.", + ApprovalPolicy = new() + { + Global = FoundryToolboxMcpGlobalApprovalMode.Always + } + }); + +builder.AddProject("service") + .WithReference(toolbox) + .WaitFor(toolbox); +``` + +**TypeScript** + +```typescript +import { FoundryToolboxMcpGlobalApprovalMode } from "./.aspire/modules/aspire.mjs"; + +const foundry = await builder.addFoundry("foundry"); +const project = await foundry.addProject("my-project"); +const search = await builder.addAzureSearch("search"); + +const toolbox = await project.addToolbox("field-tools"); +await toolbox.withDescription("Tools for field technicians."); +await toolbox.withWebSearchTool({ + name: "web-search", + description: "Search the public web." +}); +await toolbox.withAISearchTool( + "knowledge-base", + search, + "docs", + "Search the internal knowledge base."); +await toolbox.withMcpTool("inventory", "https://inventory.example.com/mcp", { + serverDescription: "Inventory MCP server.", + approvalPolicy: { + global: FoundryToolboxMcpGlobalApprovalMode.Always + } +}); + +const service = await builder.addNodeApp("service", "../service", "server.js"); +await service.withReference(toolbox); +await service.waitFor(toolbox); +``` + +Azure AI Search tools reference an index that must already exist and contain the data the tool should +search. `AddAzureSearch` provisions the Search service, but neither it nor `WithAISearchTool` creates +or populates the named index. + +The identity running `aspire run` or `aspire deploy` must have the +[Foundry User role](https://learn.microsoft.com/azure/foundry/concepts/rbac-foundry) on the Foundry +project so Aspire can manage Toolbox versions. Deployed compute resources that reference the Toolbox +receive this role automatically on that project. + +MCP endpoints must be reachable from the Foundry data plane over HTTPS. For local development, use +a development tunnel instead of a localhost endpoint. Inline credentials and headers are not +supported. Connection-authenticated MCP servers are not currently supported by this integration. + +MCP approval policies are declarations returned as Toolbox discovery metadata. The Toolbox service +does not enforce approval when a client sends `tools/call`; the consuming application must inspect +the metadata and obtain any required approval before invocation. A custom policy can classify +individual MCP tool names: + +```csharp +new FoundryToolboxMcpApprovalPolicy +{ + Always = new() + { + ToolNames = ["delete-item", "update-item"], + ReadOnly = false + }, + Never = new() + { + ToolNames = ["get-item"], + ReadOnly = true + } +} +``` + +The default consumer endpoint always serves the promoted Toolbox version. Set +`FoundryToolboxResource.Version` only when a consumer must target a specific immutable version. + +### Existing Toolboxes + +Use the existing-resource methods to validate a remote Toolbox without resolving modeled tools, +checking Aspire ownership metadata, creating versions, or changing the default: + +| Method | `aspire run` | `aspire deploy` | +|--------|--------------|-----------------| +| `RunAsExisting()` | Validate existing | Reconcile managed | +| `PublishAsExisting()` | Reconcile managed | Validate existing | +| `AsExisting()` | Validate existing | Validate existing | + +Existing mode permits a Toolbox with no locally modeled tools. If `Version` is set, validation also +requires that immutable version to exist; otherwise it validates the current default and exposes the +selected value through `DeployedVersion`. + +Aspire ownership metadata provides best-effort coordination between deployments, not an atomic +lease. The Toolbox API currently has no ETag or conditional update operation. Aspire re-checks the +current default and target ownership immediately before promotion and verifies the result afterward, +then fails rather than overwriting contradictory concurrent changes. + ## Prompt agent usage Prompt agents are declarative agents defined by a model, instructions, and tools. They are always deployed to Azure Foundry — even during local development (`aspire run`) — and local services communicate with the cloud-provisioned agent. @@ -221,6 +358,7 @@ var agent2 = project.AddPromptAgent(chat, "agent-2").WithTool(codeInterp); * https://aspire.dev/integrations/gallery/ * https://aspire.dev/integrations/cloud/azure/azure-ai-foundry/azure-ai-foundry-host/ +* https://learn.microsoft.com/azure/foundry/agents/how-to/tools/toolbox * https://learn.microsoft.com/azure/ai-foundry/what-is-azure-ai-foundry * https://learn.microsoft.com/azure/ai-foundry/foundry-local/ diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs new file mode 100644 index 00000000000..e37f6d64532 --- /dev/null +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs @@ -0,0 +1,383 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO.Hashing; +using System.Text; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Azure; +using Aspire.Hosting.Foundry; + +namespace Aspire.Hosting; + +/// +/// Extension methods for adding Microsoft Foundry Toolbox resources and tools. +/// +public static class FoundryToolboxBuilderExtensions +{ + /// + /// Adds a Microsoft Foundry Toolbox endpoint to a Microsoft Foundry project. + /// + /// The resource builder for the Microsoft Foundry project. + /// The Toolbox name. + /// Optional callback used to configure the Toolbox resource. + /// A reference to the for the Toolbox resource. + /// + /// Aspire reuses the current default version when its configuration matches. Otherwise, it + /// creates and promotes a new immutable version. The + /// property pins the version used by consumers in the MCP endpoint URI; the version selected + /// by the most recent reconciliation is exposed via + /// . + /// + /// + /// + /// var foundry = builder.AddFoundry("foundry"); + /// var project = foundry.AddProject("project"); + /// var toolbox = project.AddToolbox("field-tools") + /// .WithWebSearchTool(); + /// + /// builder.AddProject<Projects.Worker>("worker") + /// .WithReference(toolbox) + /// .WaitFor(toolbox); + /// + /// + /// The resource builder. + [AspireExportIgnore(Reason = "Polyglot app hosts use the FoundryToolboxOptions overload instead.")] + public static IResourceBuilder AddToolbox( + this IResourceBuilder builder, + [ResourceName] string name, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + var toolbox = new FoundryToolboxResource(name, builder.Resource); + configure?.Invoke(toolbox); + + var roles = new HashSet + { + new(FoundryResource.FoundryUserRoleDefinitionId, "Foundry User") + }; + +#pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. + toolbox.Annotations.Add(new ReferenceRoleAssignmentAnnotation(builder.Resource, roles)); +#pragma warning restore ASPIREAZURE003 + + return builder.ApplicationBuilder.AddResource(toolbox) + .WithIconName("Toolbox") + .WithParentRelationship(builder); + } + + /// + /// Adds a Microsoft Foundry Toolbox endpoint to a Microsoft Foundry project. + /// + /// The resource builder for the Microsoft Foundry project. + /// The Toolbox name. + /// Optional Toolbox settings. + /// A reference to the for the Toolbox resource. + /// The resource builder. + [AspireExport("addToolbox")] + internal static IResourceBuilder AddToolboxForPolyglot( + this IResourceBuilder builder, + [ResourceName] string name, + FoundryToolboxOptions? options = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + return builder.AddToolbox(name, toolbox => toolbox.Version = options?.Version); + } + + /// + /// Uses an existing Microsoft Foundry Toolbox during local runs and reconciles it during deployment. + /// + /// The resource builder for the Toolbox. + /// A reference to the for chaining. + /// + /// Existing mode validates that the remote Toolbox and optional pinned + /// exist without resolving locally modeled tools or + /// mutating the Toolbox. + /// + /// The resource builder. + [AspireExport] + public static IResourceBuilder RunAsExisting( + this IResourceBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + ConfigureAsExisting(builder); + } + + return builder; + } + + /// + /// Reconciles a Microsoft Foundry Toolbox during local runs and uses an existing Toolbox during deployment. + /// + /// The resource builder for the Toolbox. + /// A reference to the for chaining. + /// + /// Existing mode validates that the remote Toolbox and optional pinned + /// exist without resolving locally modeled tools or + /// mutating the Toolbox. + /// + /// The resource builder. + [AspireExport] + public static IResourceBuilder PublishAsExisting( + this IResourceBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + ConfigureAsExisting(builder); + } + + return builder; + } + + /// + /// Uses an existing Microsoft Foundry Toolbox during local runs and deployment. + /// + /// The resource builder for the Toolbox. + /// A reference to the for chaining. + /// + /// Existing mode validates that the remote Toolbox and optional pinned + /// exist without resolving locally modeled tools or + /// mutating the Toolbox. + /// + /// The resource builder. + [AspireExport] + public static IResourceBuilder AsExisting( + this IResourceBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + ConfigureAsExisting(builder); + + return builder; + } + + /// + /// Adds a web search tool definition to the Toolbox. + /// + /// The resource builder for the Toolbox. + /// The tool name. + /// An optional description of the web search tool. + /// A reference to the for chaining. + /// The resource builder. + [AspireExport] + public static IResourceBuilder WithWebSearchTool( + this IResourceBuilder builder, + string name = "web-search", + string? description = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + if (description is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(description); + } + + builder.Resource.AddTool(new FoundryToolboxWebSearchToolDefinition(name, description)); + + return builder; + } + + /// + /// Sets the description persisted with each Toolbox version. + /// + /// The resource builder for the Toolbox. + /// The Toolbox description. + /// A reference to the for chaining. + /// The resource builder. + [AspireExport] + public static IResourceBuilder WithDescription( + this IResourceBuilder builder, + string description) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(description); + + builder.Resource.Description = description; + + return builder; + } + + /// + /// Adds an MCP tool definition to the Toolbox. + /// + /// The resource builder for the Toolbox. + /// The tool name. + /// The MCP endpoint URI. + /// Optional MCP server metadata and approval policy. + /// A reference to the for chaining. + /// The resource builder. + [AspireExportIgnore(Reason = "Polyglot app hosts use the union overload instead.")] + public static IResourceBuilder WithMcpTool( + this IResourceBuilder builder, + string name, + string endpoint, + FoundryToolboxMcpToolOptions? options = null) + { + ArgumentException.ThrowIfNullOrEmpty(endpoint); + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri) || + !FoundryToolboxMcpToolDefinition.IsFoundryReachableHttpsEndpoint(endpointUri)) + { + throw new ArgumentException( + "The MCP endpoint must be a Foundry-reachable absolute HTTPS URI.", + nameof(endpoint)); + } + + return builder.WithMcpTool(name, ReferenceExpression.Create($"{endpointUri.AbsoluteUri}"), options); + } + + /// + /// Adds an MCP tool definition to the Toolbox. + /// + /// The resource builder for the Toolbox. + /// The tool name. + /// The MCP endpoint. + /// Optional MCP server metadata and approval policy. + /// + /// During local development, the endpoint must resolve to a Foundry-reachable HTTPS URI, such + /// as an anonymous development tunnel. A localhost endpoint cannot be reached by the Foundry + /// data plane. Resource endpoints deployed with public HTTPS ingress can be referenced directly + /// when using aspire deploy. + /// + /// A reference to the for chaining. + /// The resource builder. + [AspireExportIgnore(Reason = "Polyglot app hosts use the union overload instead.")] + public static IResourceBuilder WithMcpTool( + this IResourceBuilder builder, + string name, + EndpointReference endpoint, + FoundryToolboxMcpToolOptions? options = null) + { + ArgumentNullException.ThrowIfNull(endpoint); + + return builder.WithMcpTool(name, ReferenceExpression.Create($"{endpoint}"), options); + } + + /// + /// Adds an MCP tool definition to the Toolbox. + /// + /// The resource builder for the Toolbox. + /// The tool name. + /// The MCP endpoint. A string URI, an + /// pointing at a resource endpoint, or a for cases where the + /// endpoint URL needs to be composed (for example, appending the MCP server's mount path or + /// chaining through a public ingress like a dev tunnel). + /// Optional MCP server metadata and approval policy. + /// A reference to the for chaining. + /// The resource builder. + [AspireExport("withMcpTool")] + internal static IResourceBuilder WithMcpToolForPolyglot( + this IResourceBuilder builder, + string name, + [AspireUnion(typeof(string), typeof(EndpointReference), typeof(ReferenceExpression))] object endpoint, + FoundryToolboxMcpToolOptions? options = null) + { + ArgumentNullException.ThrowIfNull(endpoint); + + return endpoint switch + { + string endpointString => builder.WithMcpTool(name, endpointString, options), + EndpointReference endpointReference => builder.WithMcpTool(name, endpointReference, options), + // ReferenceExpression lets polyglot callers compose URLs (e.g. `refExpr\`${endpoint}/mcp\``) + // because the polyglot type system can't express a templated string built from a typed + // endpoint reference any other way. + ReferenceExpression endpointExpression => builder.WithMcpTool(name, endpointExpression, options), + _ => throw new ArgumentException("Endpoint must be a string, endpoint reference, or reference expression.", nameof(endpoint)) + }; + } + + /// + /// Adds an Azure AI Search tool definition to the Toolbox. + /// + /// The resource builder for the Toolbox. + /// The tool name. + /// The Azure AI Search resource backing the tool. + /// The search index name. + /// An optional description of the Azure AI Search tool. + /// A reference to the for chaining. + /// The resource builder. + [AspireExport] + public static IResourceBuilder WithAISearchTool( + this IResourceBuilder builder, + string name, + IResourceBuilder search, + string indexName, + string? description = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentNullException.ThrowIfNull(search); + ArgumentException.ThrowIfNullOrWhiteSpace(indexName); + if (description is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(description); + } + + if (builder.Resource.IsExisting) + { + return builder; + } + + var projectBuilder = builder.ApplicationBuilder.CreateResourceBuilder(builder.Resource.Parent); + var connectionName = CreateSearchConnectionName( + builder.Resource.Parent.Name, + builder.Resource.Name, + name, + search.Resource.Name); + var connection = projectBuilder.AddSearchConnection(connectionName, search.Resource); + builder.Resource.AddTool(new FoundryToolboxAzureAISearchToolDefinition( + name, + search.Resource, + connection.Resource, + indexName, + description)); + + return builder; + } + + private static string CreateSearchConnectionName( + string projectName, + string toolboxName, + string toolName, + string searchName) + { + var identity = Encoding.UTF8.GetBytes($"{projectName}\0{toolboxName}\0{toolName}\0{searchName}"); + var hash = XxHash3.Hash(identity); + return $"toolbox-search-{Convert.ToHexString(hash).ToLowerInvariant()}"; + } + + private static void ConfigureAsExisting(IResourceBuilder builder) + { + foreach (var connection in builder.Resource.Tools + .OfType() + .Select(tool => tool.Connection)) + { + builder.ApplicationBuilder.Resources.Remove(connection); + } + + builder.Resource.ClearTools(); + builder.WithAnnotation(new FoundryToolboxExistingResourceAnnotation()); + } + + private static IResourceBuilder WithMcpTool( + this IResourceBuilder builder, + string name, + ReferenceExpression endpointExpression, + FoundryToolboxMcpToolOptions? options) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentNullException.ThrowIfNull(endpointExpression); + + builder.Resource.AddTool(new FoundryToolboxMcpToolDefinition(name, endpointExpression, options)); + + return builder; + } +} diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxOptions.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxOptions.cs new file mode 100644 index 00000000000..36ba3033c59 --- /dev/null +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxOptions.cs @@ -0,0 +1,102 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Foundry; + +/// +/// Options used when adding a Microsoft Foundry Toolbox resource to a project. +/// +[AspireDto] +internal sealed class FoundryToolboxOptions +{ + /// + /// Gets or sets the optional Toolbox version to reference. When unset, the default + /// Toolbox version is used. + /// + public string? Version { get; set; } +} + +/// +/// Options for an MCP tool in a Microsoft Foundry Toolbox. +/// +[AspireDto] +public sealed class FoundryToolboxMcpToolOptions +{ + /// + /// Gets or sets the label that identifies the MCP server in the Toolbox and prefixes its + /// discovered tool names. The Toolbox tool name is used when unset. + /// + public string? ServerLabel { get; set; } + + /// + /// Gets or sets a description of the MCP server. + /// + public string? ServerDescription { get; set; } + + /// + /// Gets or sets the declared approval policy for tools discovered from the MCP server. + /// + /// + /// The Toolbox publishes this policy as MCP discovery metadata. Applications consuming the + /// Toolbox remain responsible for enforcing approval before invoking a discovered tool. + /// + public FoundryToolboxMcpApprovalPolicy? ApprovalPolicy { get; set; } +} + +/// +/// Declares which tools discovered from an MCP server require approval. +/// +[AspireDto] +public sealed class FoundryToolboxMcpApprovalPolicy +{ + /// + /// Gets or sets a policy that applies to every tool exposed by the MCP server. + /// + /// + /// This cannot be combined with or . + /// + public FoundryToolboxMcpGlobalApprovalMode? Global { get; set; } + + /// + /// Gets or sets the filter for MCP tools that always require approval. + /// + public FoundryToolboxMcpApprovalFilter? Always { get; set; } + + /// + /// Gets or sets the filter for MCP tools that never require approval. + /// + public FoundryToolboxMcpApprovalFilter? Never { get; set; } +} + +/// +/// Selects MCP tools by name or read-only status for an approval policy. +/// +[AspireDto] +public sealed class FoundryToolboxMcpApprovalFilter +{ + /// + /// Gets or sets the names of the MCP tools selected by this filter. + /// + public string[]? ToolNames { get; set; } + + /// + /// Gets or sets whether this filter selects read-only or non-read-only MCP tools. + /// + public bool? ReadOnly { get; set; } +} + +/// +/// Declares a global approval requirement for tools discovered from an MCP server. +/// +public enum FoundryToolboxMcpGlobalApprovalMode +{ + /// + /// No discovered tool requires approval. + /// + Never, + + /// + /// Every discovered tool requires approval. + /// + Always +} diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReadinessProbe.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReadinessProbe.cs new file mode 100644 index 00000000000..9c94312b077 --- /dev/null +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReadinessProbe.cs @@ -0,0 +1,235 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace Aspire.Hosting.Foundry; + +internal sealed class FoundryToolboxReadinessProbe( + HttpClient client, + TimeSpan? timeout = null, + TimeSpan? retryDelay = null) +{ + private readonly TimeSpan _timeout = timeout ?? TimeSpan.FromMinutes(2); + private readonly TimeSpan _retryDelay = retryDelay ?? TimeSpan.FromSeconds(5); + + public async Task> WaitForToolsAsync( + Uri endpoint, + string accessToken, + IReadOnlyCollection requiredToolNames, + IReadOnlyCollection requiredMcpServerLabels, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(endpoint); + ArgumentException.ThrowIfNullOrEmpty(accessToken); + ArgumentNullException.ThrowIfNull(requiredToolNames); + ArgumentNullException.ThrowIfNull(requiredMcpServerLabels); + + using var discoveryCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + discoveryCancellation.CancelAfter(_timeout); + try + { + var initialize = await SendRequestAsync( + endpoint, + accessToken, + sessionId: null, + protocolVersion: null, + """ + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"Aspire.Hosting.Foundry","version":"1.0"}}} + """, + discoveryCancellation.Token).ConfigureAwait(false); + var negotiatedProtocol = initialize.Result + .GetProperty("protocolVersion") + .GetString(); + if (string.IsNullOrEmpty(negotiatedProtocol)) + { + throw new InvalidOperationException("Foundry Toolbox MCP initialization did not negotiate a protocol version."); + } + + await SendRequestAsync( + endpoint, + accessToken, + initialize.SessionId, + negotiatedProtocol, + """{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}""", + discoveryCancellation.Token).ConfigureAwait(false); + + var requestId = 2; + while (true) + { + var discoveredToolNames = new HashSet(StringComparer.Ordinal); + string? cursor = null; + var retryDiscovery = false; + do + { + var response = await SendRequestAsync( + endpoint, + accessToken, + initialize.SessionId, + negotiatedProtocol, + CreateToolsListPayload(requestId++, cursor), + discoveryCancellation.Token, + retryInternalServerError: true).ConfigureAwait(false); + if (response.IsRetryableFailure) + { + retryDiscovery = true; + break; + } + + foreach (var tool in response.Result.GetProperty("tools").EnumerateArray()) + { + discoveredToolNames.Add(tool.GetProperty("name").GetString() + ?? throw new InvalidOperationException("A discovered Toolbox tool did not have a name.")); + } + + // MCP paginates tools/list as: + // {"result":{"tools":[...],"nextCursor":"opaque continuation token"}} + cursor = response.Result.TryGetProperty("nextCursor", out var nextCursor) + ? nextCursor.GetString() + : null; + } + while (!string.IsNullOrEmpty(cursor)); + + var hasRequiredTools = + requiredToolNames.All(discoveredToolNames.Contains) && + requiredMcpServerLabels.All(label => + discoveredToolNames.Any(name => + name.StartsWith($"{label}.", StringComparison.Ordinal))); + var hasConfiguredExpectations = + requiredToolNames.Count > 0 || requiredMcpServerLabels.Count > 0; + if (!retryDiscovery && + hasRequiredTools && + (hasConfiguredExpectations || discoveredToolNames.Count > 0)) + { + return discoveredToolNames.ToArray(); + } + + // Toolbox tool discovery is eventually consistent immediately after reconciliation. + await Task.Delay(_retryDelay, discoveryCancellation.Token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + var expectedTools = requiredToolNames + .Concat(requiredMcpServerLabels.Select(label => $"{label}.*")) + .Order(StringComparer.Ordinal) + .ToArray(); + var expected = expectedTools.Length == 0 + ? "any tool" + : string.Join(", ", expectedTools); + throw new TimeoutException( + $"Foundry Toolbox did not discover the required tools within {_timeout}: {expected}."); + } + } + + private static string CreateToolsListPayload(int requestId, string? cursor) + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + writer.WriteString("jsonrpc", "2.0"); + writer.WriteNumber("id", requestId); + writer.WriteString("method", "tools/list"); + writer.WriteStartObject("params"); + if (cursor is not null) + { + writer.WriteString("cursor", cursor); + } + writer.WriteEndObject(); + writer.WriteEndObject(); + } + + return Encoding.UTF8.GetString(stream.GetBuffer(), 0, checked((int)stream.Length)); + } + + private async Task SendRequestAsync( + Uri endpoint, + string accessToken, + string? sessionId, + string? protocolVersion, + string payload, + CancellationToken cancellationToken, + bool retryInternalServerError = false) + { + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + request.Headers.Add("Foundry-Features", FoundryToolboxResource.PreviewFeatureHeaderValue); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + if (!string.IsNullOrEmpty(sessionId)) + { + request.Headers.Add("Mcp-Session-Id", sessionId); + } + if (!string.IsNullOrEmpty(protocolVersion)) + { + request.Headers.Add("MCP-Protocol-Version", protocolVersion); + } + request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); + using var requestDocument = JsonDocument.Parse(payload); + var expectedId = requestDocument.RootElement.TryGetProperty("id", out var requestId) + ? requestId.GetInt32() + : (int?)null; + + using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); + var responseSessionId = response.Headers.TryGetValues("Mcp-Session-Id", out var values) + ? values.Single() + : sessionId; + if (retryInternalServerError && + response.StatusCode == System.Net.HttpStatusCode.InternalServerError) + { + return new(default, responseSessionId, IsRetryableFailure: true); + } + + response.EnsureSuccessStatusCode(); + var responsePayload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(responsePayload) || expectedId is null) + { + return new(default, responseSessionId); + } + + // Streamable HTTP may return either one JSON document or SSE frames such as: + // event: message + // data: {"jsonrpc":"2.0","id":1,"result":{...}} + var responseMessages = responsePayload.TrimStart().StartsWith('{') + ? [responsePayload] + : responsePayload.Split('\n', StringSplitOptions.TrimEntries) + .Where(line => line.StartsWith("data:", StringComparison.Ordinal)) + .Select(line => line["data:".Length..].Trim()); + JsonElement? matchingResponse = null; + foreach (var responseMessage in responseMessages) + { + using var candidate = JsonDocument.Parse(responseMessage); + if (candidate.RootElement.TryGetProperty("id", out var responseId) && + responseId.ValueKind == JsonValueKind.Number && + responseId.GetInt32() == expectedId) + { + matchingResponse = candidate.RootElement.Clone(); + break; + } + } + + if (matchingResponse is null) + { + throw new InvalidOperationException( + $"The Toolbox MCP response did not contain JSON-RPC response ID {expectedId}."); + } + + if (matchingResponse.Value.TryGetProperty("error", out var error)) + { + throw new InvalidOperationException($"Toolbox MCP request failed: {error.GetRawText()}"); + } + + var result = matchingResponse.Value.TryGetProperty("result", out var resultElement) + ? resultElement.Clone() + : default; + return new(result, responseSessionId); + } + + private sealed record McpResponse( + JsonElement Result, + string? SessionId, + bool IsRetryableFailure = false); +} diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReconciler.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReconciler.cs new file mode 100644 index 00000000000..ad209a6850d --- /dev/null +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReconciler.cs @@ -0,0 +1,566 @@ +// 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; +using System.ClientModel.Primitives; +using System.IO.Hashing; +using System.Text.Json; +using Azure.AI.Projects.Agents; + +namespace Aspire.Hosting.Foundry; + +internal sealed class FoundryToolboxDeploymentDefinition +{ + internal const string ManagedByMetadataKey = "aspire-managed-by"; + internal const string ManagedByMetadataValue = "Aspire.Hosting.Foundry"; + internal const string ConfigurationHashMetadataKey = "aspire-configuration-hash"; + internal const string SchemaVersionMetadataKey = "aspire-schema-version"; + + private const int MaximumMetadataEntries = 16; + private static readonly string[] s_reservedMetadataKeys = + [ + ManagedByMetadataKey, + ConfigurationHashMetadataKey, + SchemaVersionMetadataKey + ]; + + private FoundryToolboxDeploymentDefinition( + string name, + string description, + IReadOnlyList tools, + IReadOnlyDictionary metadata, + string configurationHash) + { + Name = name; + Description = description; + Tools = tools; + Metadata = metadata; + ConfigurationHash = configurationHash; + } + + public string Name { get; } + + public string Description { get; } + + public IReadOnlyList Tools { get; } + + public IReadOnlyDictionary Metadata { get; } + + public string ConfigurationHash { get; } + + public static FoundryToolboxDeploymentDefinition Create( + string name, + string description, + IReadOnlyList tools, + IReadOnlyDictionary metadata) + { + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentException.ThrowIfNullOrWhiteSpace(description); + ArgumentNullException.ThrowIfNull(tools); + ArgumentNullException.ThrowIfNull(metadata); + + if (tools.Count == 0) + { + throw new InvalidOperationException($"Toolbox '{name}' must contain at least one tool."); + } + + var duplicateToolNames = tools + .GroupBy(tool => tool.Name, StringComparer.Ordinal) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .Order(StringComparer.Ordinal) + .ToArray(); + + if (duplicateToolNames.Length > 0) + { + throw new InvalidOperationException( + $"Toolbox '{name}' contains duplicate tool names: {string.Join(", ", duplicateToolNames)}."); + } + + var duplicateMcpServerLabels = tools + .Where(tool => tool.McpServerLabel is not null) + .GroupBy(tool => tool.McpServerLabel!, StringComparer.Ordinal) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .Order(StringComparer.Ordinal) + .ToArray(); + if (duplicateMcpServerLabels.Length > 0) + { + throw new InvalidOperationException( + $"Toolbox '{name}' contains duplicate MCP server labels: {string.Join(", ", duplicateMcpServerLabels)}."); + } + + var maximumUserMetadataEntries = MaximumMetadataEntries - s_reservedMetadataKeys.Length; + if (metadata.Count > maximumUserMetadataEntries) + { + throw new InvalidOperationException( + $"Toolbox '{name}' supports at most {maximumUserMetadataEntries} user metadata entries."); + } + + foreach (var reservedKey in s_reservedMetadataKeys) + { + if (metadata.ContainsKey(reservedKey)) + { + throw new InvalidOperationException( + $"Toolbox metadata key '{reservedKey}' is reserved for Aspire."); + } + } + + var configurationHash = ComputeConfigurationHash(description, tools, metadata); + return new(name, description, tools, metadata, configurationHash); + } + + public IDictionary CreateDeploymentMetadata() + { + var metadata = new Dictionary(Metadata, StringComparer.Ordinal) + { + [ManagedByMetadataKey] = ManagedByMetadataValue, + [ConfigurationHashMetadataKey] = ConfigurationHash, + [SchemaVersionMetadataKey] = "1" + }; + + return metadata; + } + + private static string ComputeConfigurationHash( + string description, + IReadOnlyList tools, + IReadOnlyDictionary metadata) + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + writer.WriteString("description", description); + writer.WriteStartObject("metadata"); + foreach (var item in metadata.OrderBy(item => item.Key, StringComparer.Ordinal)) + { + writer.WriteString(item.Key, item.Value); + } + writer.WriteEndObject(); + writer.WriteStartArray("tools"); + foreach (var tool in tools.OrderBy(tool => tool.Name, StringComparer.Ordinal)) + { + writer.WriteRawValue(tool.CanonicalConfiguration); + } + writer.WriteEndArray(); + writer.WriteEndObject(); + } + + var hash = XxHash3.Hash(stream.GetBuffer().AsSpan(0, checked((int)stream.Length))); + return Convert.ToHexString(hash).ToLowerInvariant(); + } +} + +internal sealed record ResolvedFoundryToolboxTool( + string Name, + ProjectsAgentTool Tool, + string CanonicalConfiguration, + string? McpServerLabel = null); + +internal sealed record FoundryToolboxState( + string DefaultVersion, + IReadOnlyList Versions) +{ + public FoundryToolboxVersionState Default => + Versions.Single(version => string.Equals(version.Version, DefaultVersion, StringComparison.Ordinal)); +} + +internal sealed record FoundryToolboxVersionState( + string Version, + IReadOnlyDictionary Metadata); + +internal interface IFoundryToolboxAdministration +{ + Task GetAsync(string name, CancellationToken cancellationToken); + + Task CreateVersionAsync( + FoundryToolboxDeploymentDefinition definition, + CancellationToken cancellationToken); + + Task PromoteVersionAsync(string name, string version, CancellationToken cancellationToken); +} + +internal sealed class AzureFoundryToolboxAdministration( + AgentToolboxes toolboxes, + Action logRetry) : IFoundryToolboxAdministration +{ + private const int ProjectEndpointReadinessMaxRetryAttempts = 11; + private static readonly TimeSpan s_projectEndpointReadinessDelay = TimeSpan.FromSeconds(5); + + public async Task GetAsync(string name, CancellationToken cancellationToken) + { + ToolboxRecord toolbox; + try + { + toolbox = await ExecuteWithProjectReadinessRetryAsync( + async token => (await toolboxes.GetToolboxAsync(name, token).ConfigureAwait(false)).Value, + cancellationToken).ConfigureAwait(false); + } + catch (ClientResultException ex) when (ex.Status == 404 && !IsProjectEndpointNotReady(ex)) + { + return null; + } + + var versions = await ExecuteWithProjectReadinessRetryAsync( + async token => + { + var result = new List(); + await foreach (var version in toolboxes.GetToolboxVersionsAsync( + name, + cancellationToken: token).ConfigureAwait(false)) + { + result.Add(new( + version.Version, + new Dictionary(version.Metadata, StringComparer.Ordinal))); + } + + return result; + }, + cancellationToken).ConfigureAwait(false); + + if (!versions.Any(version => + string.Equals(version.Version, toolbox.DefaultVersion, StringComparison.Ordinal))) + { + var defaultVersion = await ExecuteWithProjectReadinessRetryAsync( + async token => (await toolboxes.GetToolboxVersionAsync( + name, + toolbox.DefaultVersion, + token).ConfigureAwait(false)).Value, + cancellationToken).ConfigureAwait(false); + versions.Add(new( + defaultVersion.Version, + new Dictionary(defaultVersion.Metadata, StringComparer.Ordinal))); + } + + return new(toolbox.DefaultVersion, versions); + } + + public Task CreateVersionAsync( + FoundryToolboxDeploymentDefinition definition, + CancellationToken cancellationToken) + { + return ExecuteWithProjectReadinessRetryAsync( + async token => + { + var result = await toolboxes.CreateToolboxVersionAsync( + definition.Name, + definition.Tools.Select(tool => tool.Tool), + definition.Description, + definition.CreateDeploymentMetadata(), + policies: null, + token).ConfigureAwait(false); + return result.Value.Version; + }, + cancellationToken); + } + + public async Task PromoteVersionAsync( + string name, + string version, + CancellationToken cancellationToken) + { + await ExecuteWithProjectReadinessRetryAsync( + async token => + { + var options = new RequestOptions + { + CancellationToken = token + }; + await toolboxes.UpdateToolboxAsync(name, version, options).ConfigureAwait(false); + return true; + }, + cancellationToken).ConfigureAwait(false); + } + + internal static bool IsProjectEndpointNotReady(ClientResultException ex) => + ex.Status == 404 && + (ex.Message.Contains("Subdomain does not map to a resource", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("The project does not exist", StringComparison.OrdinalIgnoreCase)); + + private async Task ExecuteWithProjectReadinessRetryAsync( + Func> operation, + CancellationToken cancellationToken) + { + for (var attempt = 0; ; attempt++) + { + try + { + return await operation(cancellationToken).ConfigureAwait(false); + } + catch (ClientResultException ex) + when (IsProjectEndpointNotReady(ex) && + attempt < ProjectEndpointReadinessMaxRetryAttempts) + { + logRetry( + $"Foundry project endpoint is not ready. Retrying toolbox deployment in {s_projectEndpointReadinessDelay.TotalSeconds:n0} seconds ({attempt + 1}/{ProjectEndpointReadinessMaxRetryAttempts})."); + await Task.Delay(s_projectEndpointReadinessDelay, cancellationToken).ConfigureAwait(false); + } + } + } +} + +internal sealed class FoundryToolboxReconciler(IFoundryToolboxAdministration administration) +{ + public async Task ReconcileAsync( + FoundryToolboxDeploymentDefinition definition, + string? consumerVersion, + CancellationToken cancellationToken) + { + var result = await ReconcileAsync(definition, cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrEmpty(consumerVersion)) + { + await new FoundryToolboxExistingResourceValidator(administration) + .ValidateAsync(definition.Name, consumerVersion, cancellationToken).ConfigureAwait(false); + } + + return result; + } + + public async Task ReconcileAsync( + FoundryToolboxDeploymentDefinition definition, + CancellationToken cancellationToken) + { + var existing = await administration.GetAsync(definition.Name, cancellationToken).ConfigureAwait(false); + if (existing is null) + { + var created = await administration.CreateVersionAsync(definition, cancellationToken).ConfigureAwait(false); + await PromoteOwnedVersionAsync( + definition, + created, + observedDefaultVersion: null, + cancellationToken).ConfigureAwait(false); + return new(created, FoundryToolboxReconcileAction.CreatedAndPromoted); + } + + ValidateOwnedDefault(definition.Name, existing); + + if (existing.Default.Metadata.TryGetValue( + FoundryToolboxDeploymentDefinition.ConfigurationHashMetadataKey, + out var existingHash) && + string.Equals(existingHash, definition.ConfigurationHash, StringComparison.Ordinal)) + { + await VerifyReusedDefaultAsync( + definition, + existing.DefaultVersion, + cancellationToken).ConfigureAwait(false); + return new(existing.DefaultVersion, FoundryToolboxReconcileAction.Reused); + } + + var reusableVersion = existing.Versions.FirstOrDefault(version => + version.Metadata.TryGetValue( + FoundryToolboxDeploymentDefinition.ManagedByMetadataKey, + out var versionManagedBy) && + string.Equals( + versionManagedBy, + FoundryToolboxDeploymentDefinition.ManagedByMetadataValue, + StringComparison.Ordinal) && + version.Metadata.TryGetValue( + FoundryToolboxDeploymentDefinition.ConfigurationHashMetadataKey, + out var versionHash) && + string.Equals(versionHash, definition.ConfigurationHash, StringComparison.Ordinal)); + if (reusableVersion is not null) + { + await PromoteOwnedVersionAsync( + definition, + reusableVersion.Version, + existing.DefaultVersion, + cancellationToken).ConfigureAwait(false); + return new(reusableVersion.Version, FoundryToolboxReconcileAction.Promoted); + } + + var updated = await administration.CreateVersionAsync(definition, cancellationToken).ConfigureAwait(false); + if (!string.Equals(existing.DefaultVersion, updated, StringComparison.Ordinal)) + { + await PromoteOwnedVersionAsync( + definition, + updated, + existing.DefaultVersion, + cancellationToken).ConfigureAwait(false); + } + + return new(updated, FoundryToolboxReconcileAction.CreatedAndPromoted); + } + + private async Task VerifyReusedDefaultAsync( + FoundryToolboxDeploymentDefinition definition, + string expectedDefaultVersion, + CancellationToken cancellationToken) + { + var current = await administration.GetAsync(definition.Name, cancellationToken).ConfigureAwait(false) + ?? throw CreateConcurrentChangeException( + definition.Name, + $"default version '{expectedDefaultVersion}' matched, but the Toolbox is no longer visible"); + + ValidateOwnedDefault(definition.Name, current, concurrentChange: true); + if (!string.Equals(current.DefaultVersion, expectedDefaultVersion, StringComparison.Ordinal) || + !current.Default.Metadata.TryGetValue( + FoundryToolboxDeploymentDefinition.ConfigurationHashMetadataKey, + out var currentHash) || + !string.Equals(currentHash, definition.ConfigurationHash, StringComparison.Ordinal)) + { + throw CreateConcurrentChangeException( + definition.Name, + $"default version '{expectedDefaultVersion}' matched, but version '{current.DefaultVersion}' with a different configuration is now the default"); + } + } + + private async Task PromoteOwnedVersionAsync( + FoundryToolboxDeploymentDefinition definition, + string version, + string? observedDefaultVersion, + CancellationToken cancellationToken) + { + // Toolbox administration has no conditional update or ETag support. Re-read immediately + // before promotion and require the exact default observed while reconciling. Otherwise one + // Aspire deployment could overwrite another Aspire deployment's newer default. This is + // coordination, not an atomic lock: another writer can still update the Toolbox after the + // final read. + var before = await administration.GetAsync(definition.Name, cancellationToken).ConfigureAwait(false) + ?? throw CreateConcurrentChangeException( + definition.Name, + $"version '{version}' was created or selected, but the Toolbox is no longer visible"); + + ValidateOwnedDefault(definition.Name, before, concurrentChange: true); + ValidatePromotionTarget(definition, before, version); + if (string.Equals(before.DefaultVersion, version, StringComparison.Ordinal)) + { + return; + } + + if (observedDefaultVersion is null || + !string.Equals(before.DefaultVersion, observedDefaultVersion, StringComparison.Ordinal)) + { + var expectedDefault = observedDefaultVersion is null + ? "no default version" + : $"default version '{observedDefaultVersion}'"; + throw CreateConcurrentChangeException( + definition.Name, + $"expected {expectedDefault}, but version '{before.DefaultVersion}' is now the default"); + } + + await administration.PromoteVersionAsync( + definition.Name, + version, + cancellationToken).ConfigureAwait(false); + + var after = await administration.GetAsync(definition.Name, cancellationToken).ConfigureAwait(false) + ?? throw CreateConcurrentChangeException( + definition.Name, + "the Toolbox was no longer visible after promotion"); + + ValidateOwnedDefault(definition.Name, after, concurrentChange: true); + ValidatePromotionTarget(definition, after, version); + if (!string.Equals(after.DefaultVersion, version, StringComparison.Ordinal)) + { + throw CreateConcurrentChangeException( + definition.Name, + $"version '{version}' was promoted, but version '{after.DefaultVersion}' is now the default"); + } + } + + private static void ValidateOwnedDefault( + string name, + FoundryToolboxState state, + bool concurrentChange = false) + { + if (IsAspireManaged(state.Default)) + { + return; + } + + if (concurrentChange) + { + throw CreateConcurrentChangeException( + name, + $"default version '{state.DefaultVersion}' is not managed by Aspire"); + } + + throw new InvalidOperationException( + $"Toolbox '{name}' already exists but is not managed by Aspire. No changes were made."); + } + + private static void ValidatePromotionTarget( + FoundryToolboxDeploymentDefinition definition, + FoundryToolboxState state, + string version) + { + var target = state.Versions.FirstOrDefault(candidate => + string.Equals(candidate.Version, version, StringComparison.Ordinal)); + if (target is null) + { + throw CreateConcurrentChangeException( + definition.Name, + $"target version '{version}' is no longer visible"); + } + + if (!IsAspireManaged(target) || + !target.Metadata.TryGetValue( + FoundryToolboxDeploymentDefinition.ConfigurationHashMetadataKey, + out var configurationHash) || + !string.Equals(configurationHash, definition.ConfigurationHash, StringComparison.Ordinal)) + { + throw CreateConcurrentChangeException( + definition.Name, + $"target version '{version}' no longer has the expected Aspire ownership and configuration"); + } + } + + private static bool IsAspireManaged(FoundryToolboxVersionState version) => + version.Metadata.TryGetValue( + FoundryToolboxDeploymentDefinition.ManagedByMetadataKey, + out var managedBy) && + string.Equals( + managedBy, + FoundryToolboxDeploymentDefinition.ManagedByMetadataValue, + StringComparison.Ordinal); + + private static InvalidOperationException CreateConcurrentChangeException( + string name, + string detail) => + new( + $"Toolbox '{name}' changed concurrently: {detail}. No further changes were made."); +} + +internal sealed class FoundryToolboxExistingResourceValidator(IFoundryToolboxAdministration administration) +{ + public async Task ValidateAsync( + string name, + string? version, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + var existing = await administration.GetAsync(name, cancellationToken).ConfigureAwait(false); + if (existing is null) + { + throw new InvalidOperationException($"Toolbox '{name}' does not exist."); + } + + if (string.IsNullOrEmpty(version)) + { + return existing.DefaultVersion; + } + + if (!existing.Versions.Any(candidate => + string.Equals(candidate.Version, version, StringComparison.Ordinal))) + { + throw new InvalidOperationException( + $"Toolbox '{name}' does not contain version '{version}'."); + } + + return version; + } +} + +internal sealed record FoundryToolboxReconcileResult( + string Version, + FoundryToolboxReconcileAction Action); + +internal enum FoundryToolboxReconcileAction +{ + Reused, + Promoted, + CreatedAndPromoted, + ValidatedExisting +} diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs new file mode 100644 index 00000000000..d92a6837367 --- /dev/null +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs @@ -0,0 +1,581 @@ +// 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 Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Azure; +using Aspire.Hosting.Pipelines; +using Azure.AI.Projects; +using Azure.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.Foundry; + +/// +/// Represents a Microsoft Foundry Toolbox endpoint associated with a Foundry project. +/// +/// +/// Toolboxes are Foundry data-plane resources with no ARM or Bicep representation. Aspire +/// reconciles the desired tool definitions after the parent project is provisioned, reuses the +/// current default version when its configuration matches, and promotes a new immutable version +/// when the configuration changes. +/// +[AspireExport] +public sealed class FoundryToolboxResource : Resource, IResourceWithConnectionString +{ + internal const string DefaultApiVersion = "v1"; + internal const string PreviewFeatureHeaderValue = "Toolboxes=V1Preview"; + internal const string AuthorizationScopeValue = "https://ai.azure.com/.default"; + + private readonly List _tools = []; + + /// + /// Initializes a new instance of the class. + /// + /// The Toolbox name. + /// The parent Microsoft Foundry project resource. + /// The optional existing Toolbox version to reference. + public FoundryToolboxResource( + [ResourceName] string name, + AzureCognitiveServicesProjectResource parent, + string? version = null) + : base(name) + { + ArgumentNullException.ThrowIfNull(parent); + + Parent = parent; + Version = version; + + Annotations.Add(new PipelineStepAnnotation(context => + { + var steps = new List(); + + if (context.PipelineContext.ExecutionContext.IsRunMode) + { + steps.Add(new PipelineStep + { + Name = $"deploy-{Name}-before-start", + Description = IsExisting + ? $"Validates existing Toolbox {Name} after the application starts." + : $"Reconciles Toolbox {Name} after the application starts.", + Action = DeployBeforeStartAsync, + RequiredBySteps = [WellKnownPipelineSteps.BeforeStart], + Resource = this, + DependsOnSteps = [AzureEnvironmentResource.PrepareResourcesStepName] + }); + } + + steps.Add(new PipelineStep + { + Name = $"deploy-{Name}", + Description = IsExisting + ? $"Validates existing Toolbox {Name}." + : $"Reconciles Toolbox {Name}.", + Action = async stepContext => + { + var result = await DeployAsync( + stepContext, + message => stepContext.Logger.LogWarning("{Message}", message), + stepContext.CancellationToken).ConfigureAwait(false); + stepContext.ReportingStep.Log( + LogLevel.Information, + new MarkdownString( + IsExisting + ? $"Successfully validated **{Name}** as an existing Foundry Toolbox (version {result.Version})" + : $"Successfully reconciled **{Name}** as Foundry Toolbox (version {result.Version}, action {result.Action})")); + }, + Tags = [WellKnownPipelineTags.DeployCompute], + RequiredBySteps = [WellKnownPipelineSteps.Deploy], + Resource = this, + DependsOnSteps = + [ + WellKnownPipelineSteps.DeployPrereq, + AzureEnvironmentResource.ProvisionInfrastructureStepName + ] + }); + + return Task.FromResult>(steps); + })); + + // The Foundry data plane must be able to reach every sibling compute resource referenced + // by an MCP endpoint before the Toolbox version is created. + Annotations.Add(new PipelineConfigurationAnnotation(context => + { + if (IsExisting) + { + return Task.CompletedTask; + } + + var toolboxDeploySteps = context.GetSteps(this, WellKnownPipelineTags.DeployCompute); + + foreach (var referencedResource in GetMcpReferencedResources(context.Model)) + { + toolboxDeploySteps.DependsOn( + context.GetSteps(referencedResource, WellKnownPipelineTags.DeployCompute)); + } + + return Task.CompletedTask; + })); + } + + /// + /// Gets the parent Microsoft Foundry project resource. + /// + public AzureCognitiveServicesProjectResource Parent { get; } + + /// + /// Gets or sets the Toolbox version used by consumers. + /// + /// + /// When unset, consumers use the default Toolbox endpoint. Set this only to target a specific + /// existing immutable version for testing. Reconciliation always updates the default version + /// independently of this consumer-side pin. + /// + public string? Version { get; set; } + + /// + /// Gets or sets the API version used by the Toolbox MCP endpoint. + /// + public string ApiVersion { get; set; } = DefaultApiVersion; + + /// + /// Gets or sets the description persisted with each Toolbox version. + /// + public string Description { get; set; } = "Foundry Toolbox"; + + /// + /// Gets metadata persisted with each Toolbox version. + /// + /// + /// Aspire adds reserved ownership and configuration metadata during deployment. User metadata + /// participates in change detection and therefore creates a new version when modified. + /// + public IDictionary Metadata { get; init; } = + new Dictionary(StringComparer.Ordinal); + + /// + /// Gets the version selected by the most recent reconciliation. + /// + public StaticValueProvider DeployedVersion { get; } = new(); + + /// + /// Gets the tool definitions modeled for this Toolbox. + /// + internal IReadOnlyList Tools => _tools; + + internal bool IsExisting => + Annotations.OfType().LastOrDefault() is not null; + + /// + /// Gets the Toolbox MCP endpoint URI expression. + /// + public ReferenceExpression UriExpression => Version is { Length: > 0 } version + ? GetVersionUriExpression(version) + : ReferenceExpression.Create($"{Parent.Endpoint}/toolboxes/{Name}/mcp?api-version={ApiVersion}"); + + /// + /// Gets the connection string expression for the Toolbox MCP endpoint. + /// + public ReferenceExpression ConnectionStringExpression => UriExpression; + + internal void AddTool(FoundryToolboxToolDefinition tool) + { + ArgumentNullException.ThrowIfNull(tool); + + if (IsExisting) + { + return; + } + + if (_tools.Any(existing => string.Equals(existing.Name, tool.Name, StringComparison.Ordinal))) + { + throw new InvalidOperationException( + $"Toolbox '{Name}' already contains a tool named '{tool.Name}'."); + } + + _tools.Add(tool); + } + + internal void ClearTools() => _tools.Clear(); + + internal async Task CreateDeploymentDefinitionAsync( + CancellationToken cancellationToken) + { + var tools = new List(_tools.Count); + foreach (var tool in _tools) + { + tools.Add(await tool.ResolveAsync(cancellationToken).ConfigureAwait(false)); + } + + return FoundryToolboxDeploymentDefinition.Create( + Name, + Description, + tools, + new Dictionary(Metadata, StringComparer.Ordinal)); + } + + IEnumerable> IResourceWithConnectionString.GetConnectionProperties() + { + yield return new("Name", ReferenceExpression.Create($"{Name}")); + yield return new("ProjectEndpoint", ReferenceExpression.Create($"{Parent.Endpoint}")); + yield return new("Uri", UriExpression); + yield return new("ApiVersion", ReferenceExpression.Create($"{ApiVersion}")); + yield return new("FoundryFeatures", ReferenceExpression.Create($"{PreviewFeatureHeaderValue}")); + yield return new("AuthorizationScope", ReferenceExpression.Create($"{AuthorizationScopeValue}")); + + if (Version is { Length: > 0 } version) + { + yield return new("Version", ReferenceExpression.Create($"{version}")); + } + } + + private async Task DeployAsync( + PipelineStepContext context, + Action logRetry, + CancellationToken cancellationToken) + { + var administration = await CreateAdministrationAsync( + context, + logRetry, + cancellationToken).ConfigureAwait(false); + + if (IsExisting) + { + var version = await new FoundryToolboxExistingResourceValidator(administration) + .ValidateAsync(Name, Version, cancellationToken).ConfigureAwait(false); + DeployedVersion.Set(version); + + return new(version, FoundryToolboxReconcileAction.ValidatedExisting); + } + + var definition = await CreateDeploymentDefinitionAsync(cancellationToken).ConfigureAwait(false); + var result = await new FoundryToolboxReconciler(administration) + .ReconcileAsync(definition, Version, cancellationToken).ConfigureAwait(false); + DeployedVersion.Set(result.Version); + + return result; + } + + private async Task CreateAdministrationAsync( + PipelineStepContext context, + Action logRetry, + CancellationToken cancellationToken) + { + var projectEndpoint = await Parent.Endpoint.GetValueAsync(cancellationToken).ConfigureAwait(false); + if (!Uri.TryCreate(projectEndpoint, UriKind.Absolute, out var endpoint) || + endpoint.Scheme != Uri.UriSchemeHttps) + { + throw new InvalidOperationException( + $"Foundry project '{Parent.Name}' did not resolve to an absolute HTTPS endpoint."); + } + + endpoint = new Uri(endpoint.GetLeftPart(UriPartial.Path).TrimEnd('/')); + + var administration = context.Services.GetService(); + if (administration is null) + { + var credential = context.Services.GetRequiredService().TokenCredential; + var clientOptions = new AIProjectClientOptions(); + clientOptions.AddPolicy(new FoundryToolboxFeaturesPolicy(), PipelinePosition.PerCall); + var projectClient = new AIProjectClient(endpoint, credential, clientOptions); + administration = new AzureFoundryToolboxAdministration( + projectClient.AgentAdministrationClient.GetAgentToolboxes(), + logRetry); + } + + return administration; + } + + private Task DeployBeforeStartAsync(PipelineStepContext context) + { + if (context.ExecutionContext.IsRunMode) + { + StartRunModeDeployment(context); + } + + return Task.CompletedTask; + } + + private void StartRunModeDeployment(PipelineStepContext context) + { + // MCP endpoints can reference local compute that starts after the before-start pipeline. + // Reconcile in the background so the application can start and make those endpoints reachable. + var lifetime = context.Services.GetRequiredService(); + var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + context.CancellationToken, + lifetime.ApplicationStopping); + + _ = Task.Run(async () => + { + try + { + await DeployForRunModeAsync(context, linkedCts.Token).ConfigureAwait(false); + } + finally + { + linkedCts.Dispose(); + } + }, CancellationToken.None); + } + + private async Task DeployForRunModeAsync( + PipelineStepContext context, + CancellationToken cancellationToken) + { + var notificationService = context.Services.GetRequiredService(); + var model = context.Services.GetRequiredService(); + var logger = context.Services.GetRequiredService().GetLogger(this); + + try + { + await notificationService.PublishUpdateAsync(this, snapshot => snapshot with + { + State = new("Waiting for dependencies", KnownResourceStateStyles.Info) + }).ConfigureAwait(false); + + await WaitForProjectAndToolsAsync( + notificationService, + model, + cancellationToken).ConfigureAwait(false); + + await notificationService.PublishUpdateAsync(this, snapshot => snapshot with + { + State = new( + IsExisting ? "Validating existing Toolbox" : "Reconciling Toolbox", + KnownResourceStateStyles.Info) + }).ConfigureAwait(false); + + var result = await DeployAsync( + context, + message => logger.LogWarning("{Message}", message), + cancellationToken).ConfigureAwait(false); + + await notificationService.PublishUpdateAsync(this, snapshot => snapshot with + { + State = new("Waiting for Toolbox tools", KnownResourceStateStyles.Info) + }).ConfigureAwait(false); + await WaitForToolDiscoveryAsync( + context, + result.Version, + cancellationToken).ConfigureAwait(false); + + if (IsExisting) + { + logger.LogInformation( + "Validated existing Toolbox '{ToolboxName}' at version {Version}.", + Name, + result.Version); + } + else + { + logger.LogInformation( + "Reconciled Toolbox '{ToolboxName}' at version {Version} with action {Action}.", + Name, + result.Version, + result.Action); + } + + await notificationService.PublishUpdateAsync(this, snapshot => snapshot with + { + State = new(KnownResourceStates.Running, KnownResourceStateStyles.Success), + Properties = + [ + new("Toolbox version", result.Version), + new(IsExisting ? "Validation action" : "Reconciliation action", result.Action.ToString()) + ] + }).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception ex) + { + if (IsExisting) + { + logger.LogError(ex, "Failed to validate existing Toolbox '{ToolboxName}'.", Name); + } + else + { + logger.LogError(ex, "Failed to reconcile Toolbox '{ToolboxName}'.", Name); + } + + await notificationService.PublishUpdateAsync(this, snapshot => snapshot with + { + State = new(KnownResourceStates.FailedToStart, KnownResourceStateStyles.Error) + }).ConfigureAwait(false); + } + } + + private async Task WaitForToolDiscoveryAsync( + PipelineStepContext context, + string reconciledVersion, + CancellationToken cancellationToken) + { + var endpointValue = await GetVersionUriExpression(reconciledVersion) + .GetValueAsync(cancellationToken).ConfigureAwait(false); + if (!Uri.TryCreate(endpointValue, UriKind.Absolute, out var endpoint) || + endpoint.Scheme != Uri.UriSchemeHttps) + { + throw new InvalidOperationException( + $"Toolbox '{Name}' did not resolve to an absolute HTTPS endpoint."); + } + + var credential = context.Services.GetRequiredService().TokenCredential; + var accessToken = await credential.GetTokenAsync( + new TokenRequestContext([AuthorizationScopeValue]), + cancellationToken).ConfigureAwait(false); + var requiredToolNames = _tools + .Where(tool => tool is not FoundryToolboxMcpToolDefinition) + .Select(tool => tool.Name) + .ToArray(); + var requiredMcpServerLabels = _tools + .OfType() + .Select(tool => tool.ServerLabel) + .ToArray(); + var client = context.Services.GetRequiredService().CreateClient(); + await new FoundryToolboxReadinessProbe(client).WaitForToolsAsync( + endpoint, + accessToken.Token, + requiredToolNames, + requiredMcpServerLabels, + cancellationToken).ConfigureAwait(false); + } + + internal ReferenceExpression GetVersionUriExpression(string version) => + ReferenceExpression.Create( + $"{Parent.Endpoint}/toolboxes/{Name}/versions/{version}/mcp?api-version={ApiVersion}"); + + private async Task WaitForProjectAndToolsAsync( + ResourceNotificationService notificationService, + DistributedApplicationModel model, + CancellationToken cancellationToken) + { + if (Parent is IAzureResource { ProvisioningTaskCompletionSource: { } projectProvisioning }) + { + await projectProvisioning.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + else + { + await notificationService.WaitForResourceAsync( + Parent.Name, + KnownResourceStates.Running, + cancellationToken).ConfigureAwait(false); + } + + if (IsExisting) + { + return; + } + + var connectionProvisioningTasks = _tools + .OfType() + .Select(tool => tool.Connection.ProvisioningTaskCompletionSource?.Task) + .OfType() + .Select(task => task.WaitAsync(cancellationToken)); + + var mcpResourceWaits = GetMcpReferencedResources(model) + .Where(resource => resource is IComputeResource and IResourceWithWaitSupport) + .Select(resource => WaitForMcpResourceAsync( + notificationService, + resource, + cancellationToken)); + + await Task.WhenAll(connectionProvisioningTasks.Concat(mcpResourceWaits)).ConfigureAwait(false); + } + + internal static async Task WaitForMcpResourceAsync( + ResourceNotificationService notificationService, + IResource resource, + CancellationToken cancellationToken) + { + var state = await notificationService.WaitForResourceAsync( + resource.Name, + [KnownResourceStates.Running, .. KnownResourceStates.TerminalStates], + cancellationToken).ConfigureAwait(false); + if (!StringComparers.ResourceState.Equals(state, KnownResourceStates.Running)) + { + throw new InvalidOperationException( + $"MCP dependency '{resource.Name}' entered terminal state '{state}' before it became ready."); + } + } + + private IEnumerable GetMcpReferencedResources(DistributedApplicationModel model) + { + // Publish transformations can replace a referenced compute resource while endpoint + // expressions still point at the original instance. Match by resource name so the + // Toolbox deploy step retains its dependency on the replacement resource. + var modelResourceNames = new HashSet( + model.Resources.Select(resource => resource.Name), + StringComparers.ResourceName); + var seenResourceNames = new HashSet(StringComparers.ResourceName); + + foreach (var tool in _tools.OfType()) + { + foreach (var referencedResource in WalkValueReferences(tool.EndpointExpression).OfType()) + { + if (!ReferenceEquals(referencedResource, this) && + modelResourceNames.Contains(referencedResource.Name) && + seenResourceNames.Add(referencedResource.Name)) + { + yield return referencedResource; + } + } + } + } + + private static IEnumerable WalkValueReferences(object root) + { + var stack = new Stack(); + var visited = new HashSet(ReferenceEqualityComparer.Instance); + stack.Push(root); + + while (stack.Count > 0) + { + var current = stack.Pop(); + if (!visited.Add(current)) + { + continue; + } + + yield return current; + + if (current is IValueWithReferences valueWithReferences) + { + foreach (var reference in valueWithReferences.References) + { + if (reference is not null) + { + stack.Push(reference); + } + } + } + } + } +} + +internal sealed class FoundryToolboxExistingResourceAnnotation : IResourceAnnotation; + +internal sealed class FoundryToolboxFeaturesPolicy : PipelinePolicy +{ + private const string HeaderName = "Foundry-Features"; + + public override void Process( + PipelineMessage message, + IReadOnlyList pipeline, + int currentIndex) + { + message.Request.Headers.Add(HeaderName, FoundryToolboxResource.PreviewFeatureHeaderValue); + ProcessNext(message, pipeline, currentIndex); + } + + public override ValueTask ProcessAsync( + PipelineMessage message, + IReadOnlyList pipeline, + int currentIndex) + { + message.Request.Headers.Add(HeaderName, FoundryToolboxResource.PreviewFeatureHeaderValue); + return ProcessNextAsync(message, pipeline, currentIndex); + } +} diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs new file mode 100644 index 00000000000..b557d5394cf --- /dev/null +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs @@ -0,0 +1,432 @@ +// 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; + +/// +/// Base type for Microsoft Foundry Toolbox tool definitions. +/// +internal abstract class FoundryToolboxToolDefinition +{ + private protected FoundryToolboxToolDefinition(string name) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + Name = name; + } + + /// + /// Gets the tool name. + /// + public string Name { get; } + + internal abstract ValueTask ResolveAsync(CancellationToken cancellationToken); +} + +/// +/// Describes a web search tool in a Microsoft Foundry Toolbox. +/// +internal sealed class FoundryToolboxWebSearchToolDefinition : FoundryToolboxToolDefinition +{ + internal FoundryToolboxWebSearchToolDefinition(string name, string? description = null) + : base(name) + { + Description = description; + } + + public string? Description { get; } + + internal override ValueTask 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 against default-ALC SCM, + // while ModelReaderWriter.Write runs from probe-ALC SCM and checks + // `model is IPersistableModel` 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(json, ModelReaderWriterOptions.Json, AzureAIProjectsAgentsContext.Default); + return new ValueTask( + new ResolvedFoundryToolboxTool(Name, agentTool!, json.ToString())); + } +} + +/// +/// Describes an MCP tool in a Microsoft Foundry Toolbox. +/// +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); + } + } + + /// + /// Gets the MCP endpoint expression for the tool. + /// + public ReferenceExpression EndpointExpression { get; } + + public string ServerLabel { get; } + + public string? ServerDescription { get; } + + internal ResolvedFoundryToolboxMcpApprovalPolicy? ApprovalPolicy { get; } + + internal override async ValueTask 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": "", + // "server_url": "" // 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( + 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 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(); + } +} + +/// +/// Describes an Azure AI Search tool in a Microsoft Foundry Toolbox. +/// +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; + } + + /// + /// Gets the Azure AI Search resource backing this tool. + /// + public AzureSearchResource SearchResource { get; } + + /// + /// Gets the Foundry project connection resource used by the tool. + /// + public AzureCognitiveServicesProjectConnectionResource Connection { get; } + + /// + /// Gets the Azure AI Search index name. + /// + public string IndexName { get; } + + public string? Description { get; } + + internal override async ValueTask 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( + json, + ModelReaderWriterOptions.Json, + AzureAIProjectsAgentsContext.Default)!; + + return new ResolvedFoundryToolboxTool(Name, tool, json.ToString()); + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/Aspire.Deployment.EndToEnd.Tests.csproj b/tests/Aspire.Deployment.EndToEnd.Tests/Aspire.Deployment.EndToEnd.Tests.csproj index b3802cd369f..a270dfbb62e 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/Aspire.Deployment.EndToEnd.Tests.csproj +++ b/tests/Aspire.Deployment.EndToEnd.Tests/Aspire.Deployment.EndToEnd.Tests.csproj @@ -50,7 +50,9 @@ + + diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs index 7587f0a7ecc..aea770b62de 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs @@ -1,8 +1,21 @@ // 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 AAIP001 // Toolbox APIs are experimental. + +using System.ClientModel.Primitives; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; using Aspire.Cli.Resources; using Aspire.Deployment.EndToEnd.Tests.Helpers; +using Azure; +using Azure.AI.Projects; +using Azure.AI.Projects.Agents; +using Azure.Core; +using Azure.Search.Documents.Indexes; +using Azure.Search.Documents.Indexes.Models; using Hex1b.Automation; using Xunit; @@ -17,6 +30,9 @@ public sealed class FoundryHostedAgentDeploymentTests(ITestOutputHelper output) // Foundry deployments can take longer than standard ACA due to AI resource provisioning. private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(45); + // This scenario deploys twice; its two phase limits total 50 minutes before setup and inspection. + private static readonly TimeSpan s_toolboxTestTimeout = TimeSpan.FromMinutes(70); + [Fact] [ActiveIssue("https://github.com/microsoft/aspire/issues/16330")] public async Task DeployFoundryHostedAgentToAzure() @@ -29,6 +45,644 @@ public async Task DeployFoundryHostedAgentToAzure() await DeployFoundryHostedAgentToAzureCore(cancellationToken); } + [Fact] + public async Task DeployFoundryToolboxToAzure() + { + using var cts = new CancellationTokenSource(s_toolboxTestTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var startTime = DateTime.UtcNow; + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("foundry-toolbox"); + const string projectName = "FoundryToolbox"; + + try + { + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); + var pendingRun = terminal.RunAsync(cancellationToken); + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + await auto.PrepareEnvironmentAsync(workspace, counter); + await auto.InstallCurrentBuildAspireCliAsync(counter, output); + await auto.AspireNewAsync(projectName, counter, useRedisCache: false); + await auto.TypeAsync($"cd {projectName}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.TypeAsync("aspire add Aspire.Hosting.Foundry"); + await auto.EnterAsync(); + await auto.WaitForAspireAddCompletionAsync(counter); + + var appHostFilePath = Path.Combine( + workspace.WorkspaceRoot.FullName, + projectName, + $"{projectName}.AppHost", + "AppHost.cs"); + var appHostContent = File.ReadAllText(appHostFilePath); + appHostContent = "using Aspire.Hosting.Foundry;\n" + appHostContent; + appHostContent = appHostContent.Replace( + "builder.Build().Run();", + """ + var foundry = builder.AddFoundry("aif-myfoundry"); + var foundryProject = foundry.AddProject("proj-myproject"); + var search = builder.AddAzureSearch("search"); + + foundryProject.AddToolbox("field-tools") + .WithDescription("Tools for field technicians.") + .WithWebSearchTool("web-search", "Search the public web.") + .WithMcpTool( + "microsoft-learn", + "https://learn.microsoft.com/api/mcp", + new FoundryToolboxMcpToolOptions + { + ServerDescription = "Search Microsoft Learn.", + ApprovalPolicy = new() + { + Global = FoundryToolboxMcpGlobalApprovalMode.Always + } + }) + .WithAISearchTool( + "knowledge-base", + search, + "docs", + "Search the internal knowledge base."); + + builder.Build().Run(); + """); + File.WriteAllText(appHostFilePath, appHostContent); + + await auto.TypeAsync($"cd {projectName}.AppHost"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + await auto.TypeAsync( + $"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=swedencentral && export AZURE__RESOURCEGROUP={resourceGroupName}" + + $" && export AZURE__SUBSCRIPTIONID={subscriptionId}" + + " && export AZURE__TENANTID=$(az account show --query tenantId -o tsv)"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // The first deployment creates a Toolbox version; the second must reconcile to the same + // immutable version rather than producing another one. + await auto.TypeAsync("aspire deploy --clear-cache"); + await auto.EnterAsync(); + await auto.WaitForPipelineSuccessAsync(timeout: TimeSpan.FromMinutes(35), counter: counter); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + + var credential = AzureAuthenticationHelpers.GetAzureCredential(); + var resources = await GetToolboxTestResourcesAsync( + subscriptionId, + resourceGroupName, + credential, + cancellationToken); + await EnsureSearchIndexExistsAsync(resources, credential, cancellationToken); + var firstDeployment = await InspectToolboxAsync( + resources.ProjectEndpoint, + credential, + cancellationToken); + AssertToolboxDefinition(firstDeployment); + Assert.Equal(1, firstDeployment.VersionCount); + + var discoveredTools = await ListToolboxToolsAsync( + resources.ProjectEndpoint, + credential, + cancellationToken); + Assert.Contains("knowledge-base", discoveredTools); + + // The first pipeline banner remains visible, so clear it before waiting for the redeploy. + await auto.TypeAsync("clear"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.TypeAsync("aspire deploy --clear-cache"); + await auto.EnterAsync(); + await auto.WaitForPipelineSuccessAsync(timeout: TimeSpan.FromMinutes(15), counter: counter); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + + var secondDeployment = await InspectToolboxAsync( + resources.ProjectEndpoint, + credential, + cancellationToken); + Assert.Equal(firstDeployment.DefaultVersion, secondDeployment.DefaultVersion); + Assert.Equal(firstDeployment.ConfigurationHash, secondDeployment.ConfigurationHash); + Assert.Equal(firstDeployment.VersionCount, secondDeployment.VersionCount); + + await auto.TypeAsync( + $"az group show -n \"{resourceGroupName}\" --query name -o tsv"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync(resourceGroupName, timeout: TimeSpan.FromMinutes(2)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + await pendingRun; + + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployFoundryToolboxToAzure), + resourceGroupName, + new Dictionary(), + DateTime.UtcNow - startTime); + } + catch (Exception ex) + { + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployFoundryToolboxToAzure), + resourceGroupName, + ex.Message, + ex.StackTrace); + throw; + } + finally + { + TriggerCleanupResourceGroup(resourceGroupName, output); + DeploymentReporter.ReportCleanupStatus( + resourceGroupName, + success: true, + "Cleanup triggered (fire-and-forget)"); + } + } + + private static async Task GetToolboxTestResourcesAsync( + string subscriptionId, + string resourceGroupName, + TokenCredential credential, + CancellationToken cancellationToken) + { + var managementToken = await credential.GetTokenAsync( + new TokenRequestContext(["https://management.azure.com/.default"]), + cancellationToken); + using var client = new HttpClient(); + var resourcesUri = new Uri( + $"https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/resources?api-version=2021-04-01"); + using var resourcesRequest = new HttpRequestMessage(HttpMethod.Get, resourcesUri); + resourcesRequest.Headers.Authorization = + new AuthenticationHeaderValue("Bearer", managementToken.Token); + using var resourcesResponse = await client.SendAsync(resourcesRequest, cancellationToken); + resourcesResponse.EnsureSuccessStatusCode(); + using var resourcesDocument = JsonDocument.Parse( + await resourcesResponse.Content.ReadAsStringAsync(cancellationToken)); + + var resources = resourcesDocument.RootElement.GetProperty("value").EnumerateArray().ToArray(); + var project = resources.Single(resource => + string.Equals( + resource.GetProperty("type").GetString(), + "Microsoft.CognitiveServices/accounts/projects", + StringComparison.OrdinalIgnoreCase)); + var search = resources.Single(resource => + string.Equals( + resource.GetProperty("type").GetString(), + "Microsoft.Search/searchServices", + StringComparison.OrdinalIgnoreCase)); + + var projectId = project.GetProperty("id").GetString() + ?? throw new InvalidOperationException("The deployed Foundry project did not have a resource ID."); + using var projectRequest = new HttpRequestMessage( + HttpMethod.Get, + $"https://management.azure.com{projectId}?api-version=2025-06-01"); + projectRequest.Headers.Authorization = + new AuthenticationHeaderValue("Bearer", managementToken.Token); + using var projectResponse = await client.SendAsync(projectRequest, cancellationToken); + projectResponse.EnsureSuccessStatusCode(); + using var projectDocument = JsonDocument.Parse( + await projectResponse.Content.ReadAsStringAsync(cancellationToken)); + var projectEndpoint = projectDocument.RootElement + .GetProperty("properties") + .GetProperty("endpoints") + .GetProperty("AI Foundry API") + .GetString(); + + return new( + new Uri(projectEndpoint + ?? throw new InvalidOperationException("The deployed Foundry project did not expose an endpoint.")), + search.GetProperty("name").GetString() + ?? throw new InvalidOperationException("The deployed Search service did not have a name."), + search.GetProperty("id").GetString() + ?? throw new InvalidOperationException("The deployed Search service did not have a resource ID.")); + } + + private static async Task EnsureSearchIndexExistsAsync( + ToolboxTestResources resources, + TokenCredential credential, + CancellationToken cancellationToken) + { + var managementToken = await credential.GetTokenAsync( + new TokenRequestContext(["https://management.azure.com/.default"]), + cancellationToken); + var principalId = GetPrincipalId(managementToken.Token); + using var client = new HttpClient(); + using var roleAssignmentRequest = new HttpRequestMessage( + HttpMethod.Put, + $"https://management.azure.com{resources.SearchResourceId}/providers/Microsoft.Authorization/roleAssignments/{Guid.NewGuid():D}?api-version=2022-04-01"); + roleAssignmentRequest.Headers.Authorization = + new AuthenticationHeaderValue("Bearer", managementToken.Token); + roleAssignmentRequest.Content = JsonContent.Create(new + { + properties = new + { + roleDefinitionId = + $"/subscriptions/{AzureAuthenticationHelpers.GetSubscriptionId()}/providers/Microsoft.Authorization/roleDefinitions/7ca78c08-252a-4471-8644-bb5ff32d4ba0", + principalId + } + }); + using var roleAssignmentResponse = await client.SendAsync( + roleAssignmentRequest, + cancellationToken); + roleAssignmentResponse.EnsureSuccessStatusCode(); + + var indexClient = new SearchIndexClient( + new Uri($"https://{resources.SearchServiceName}.search.windows.net"), + credential); + var index = new SearchIndex("docs") + { + Fields = + { + new SimpleField("id", SearchFieldDataType.String) + { + IsKey = true, + IsFilterable = true + }, + new SearchableField("content") + } + }; + using var rbacPropagationCancellation = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + rbacPropagationCancellation.CancelAfter(TimeSpan.FromMinutes(10)); + try + { + while (true) + { + try + { + await indexClient.CreateOrUpdateIndexAsync( + index, + cancellationToken: rbacPropagationCancellation.Token); + break; + } + catch (RequestFailedException ex) when (ex.Status == 403) + { + // Azure RBAC propagation can take up to ten minutes after the test principal + // receives Search Service Contributor on the newly provisioned service. + // See https://learn.microsoft.com/azure/role-based-access-control/troubleshooting#role-assignment-changes-are-not-being-detected. + await Task.Delay( + TimeSpan.FromSeconds(5), + rbacPropagationCancellation.Token); + } + } + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException( + "Search Service Contributor did not propagate within ten minutes."); + } + } + + private static string GetPrincipalId(string accessToken) + { + var segments = accessToken.Split('.'); + if (segments.Length < 2) + { + throw new InvalidOperationException("The Azure access token was not a JWT."); + } + + var payload = segments[1].Replace('-', '+').Replace('_', '/'); + payload = payload.PadRight(payload.Length + ((4 - payload.Length % 4) % 4), '='); + using var document = JsonDocument.Parse(Convert.FromBase64String(payload)); + return document.RootElement.GetProperty("oid").GetString() + ?? throw new InvalidOperationException("The Azure access token did not contain an object ID."); + } + + private static async Task InspectToolboxAsync( + Uri projectEndpoint, + TokenCredential credential, + CancellationToken cancellationToken) + { + var options = new AIProjectClientOptions(); + options.AddPolicy(new ToolboxFeaturesPolicy(), PipelinePosition.PerCall); + var projectClient = new AIProjectClient(projectEndpoint, credential, options); + var toolboxes = projectClient.AgentAdministrationClient.GetAgentToolboxes(); + var toolbox = (await toolboxes.GetToolboxAsync("field-tools", cancellationToken)).Value; + var versions = new List(); + await foreach (var version in toolboxes.GetToolboxVersionsAsync( + "field-tools", + cancellationToken: cancellationToken)) + { + versions.Add(version); + } + + var defaultVersion = (await toolboxes.GetToolboxVersionAsync( + "field-tools", + toolbox.DefaultVersion, + cancellationToken)).Value; + var configurationHash = defaultVersion.Metadata["aspire-configuration-hash"]; + var serializedTools = defaultVersion.Tools + .Select(SerializeTool) + .ToArray(); + + return new( + toolbox.DefaultVersion, + configurationHash, + versions.Count, + defaultVersion.Description, + new Dictionary(defaultVersion.Metadata, StringComparer.Ordinal), + serializedTools); + } + + private static JsonElement SerializeTool(ProjectsAgentTool tool) + { + using var document = JsonDocument.Parse(ModelReaderWriter.Write( + tool, + ModelReaderWriterOptions.Json, + AzureAIProjectsAgentsContext.Default)); + + return document.RootElement.Clone(); + } + + private static void AssertToolboxDefinition(ToolboxDeploymentSnapshot snapshot) + { + Assert.Equal("Tools for field technicians.", snapshot.Description); + Assert.Equal("Aspire.Hosting.Foundry", snapshot.Metadata["aspire-managed-by"]); + Assert.Equal("1", snapshot.Metadata["aspire-schema-version"]); + Assert.Equal(3, snapshot.Tools.Count); + + var webSearch = snapshot.Tools.Single(tool => + tool.GetProperty("type").GetString() == "web_search"); + Assert.Equal("web-search", webSearch.GetProperty("name").GetString()); + Assert.Equal("Search the public web.", webSearch.GetProperty("description").GetString()); + + var mcp = snapshot.Tools.Single(tool => + tool.GetProperty("type").GetString() == "mcp"); + Assert.Equal("microsoft-learn", mcp.GetProperty("server_label").GetString()); + Assert.Equal( + "https://learn.microsoft.com/api/mcp", + mcp.GetProperty("server_url").GetString()); + Assert.Equal("Search Microsoft Learn.", mcp.GetProperty("server_description").GetString()); + Assert.Equal("always", mcp.GetProperty("require_approval").GetString()); + + var search = snapshot.Tools.Single(tool => + tool.GetProperty("type").GetString() == "azure_ai_search"); + Assert.Equal( + "docs", + search.GetProperty("azure_ai_search") + .GetProperty("indexes")[0] + .GetProperty("index_name") + .GetString()); + } + + private static async Task> ListToolboxToolsAsync( + Uri projectEndpoint, + TokenCredential credential, + CancellationToken cancellationToken) + { + var accessToken = await credential.GetTokenAsync( + new TokenRequestContext(["https://ai.azure.com/.default"]), + cancellationToken); + var endpoint = new Uri( + projectEndpoint, + $"{projectEndpoint.AbsolutePath.TrimEnd('/')}/toolboxes/field-tools/mcp?api-version=v1"); + using var client = new HttpClient(); + + var initialize = await SendMcpRequestAsync( + client, + endpoint, + accessToken.Token, + sessionId: null, + protocolVersion: null, + """ + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"Aspire.Deployment.EndToEnd.Tests","version":"1.0"}}} + """, + cancellationToken); + var negotiatedProtocol = initialize.Result + .GetProperty("protocolVersion") + .GetString(); + Assert.False(string.IsNullOrEmpty(negotiatedProtocol)); + + await SendMcpRequestAsync( + client, + endpoint, + accessToken.Token, + initialize.SessionId, + negotiatedProtocol, + """{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}""", + cancellationToken); + using var discoveryCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + discoveryCancellation.CancelAfter(TimeSpan.FromMinutes(2)); + try + { + var requestId = 2; + while (true) + { + var toolNames = new HashSet(StringComparer.Ordinal); + string? cursor = null; + var retryDiscovery = false; + do + { + McpResponse tools; + try + { + tools = await SendMcpRequestAsync( + client, + endpoint, + accessToken.Token, + initialize.SessionId, + negotiatedProtocol, + CreateToolsListPayload(requestId++, cursor), + discoveryCancellation.Token); + } + catch (HttpRequestException ex) + when (ex.StatusCode == System.Net.HttpStatusCode.InternalServerError) + { + // Foundry documents HTTP 500 from tools/list as transient while tool + // discovery converges immediately after Toolbox provisioning. + // See https://learn.microsoft.com/azure/foundry/agents/how-to/tools/toolbox#troubleshooting. + retryDiscovery = true; + break; + } + + foreach (var tool in tools.Result.GetProperty("tools").EnumerateArray()) + { + toolNames.Add(tool.GetProperty("name").GetString() + ?? throw new InvalidOperationException("A discovered MCP tool did not have a name.")); + } + + cursor = tools.Result.TryGetProperty("nextCursor", out var nextCursor) + ? nextCursor.GetString() + : null; + } + while (!string.IsNullOrEmpty(cursor)); + + if (!retryDiscovery && toolNames.Contains("knowledge-base")) + { + return toolNames.ToArray(); + } + + // Toolbox tool discovery is eventually consistent immediately after provisioning. + await Task.Delay(TimeSpan.FromSeconds(5), discoveryCancellation.Token); + } + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException( + "Foundry Toolbox did not discover the 'knowledge-base' Azure AI Search tool within two minutes."); + } + } + + private static string CreateToolsListPayload(int requestId, string? cursor) + { + // MCP tools/list pagination sends an opaque cursor in the next request: + // {"jsonrpc":"2.0","id":3,"method":"tools/list","params":{"cursor":"opaque"}} + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + writer.WriteString("jsonrpc", "2.0"); + writer.WriteNumber("id", requestId); + writer.WriteString("method", "tools/list"); + writer.WriteStartObject("params"); + if (cursor is not null) + { + writer.WriteString("cursor", cursor); + } + writer.WriteEndObject(); + writer.WriteEndObject(); + } + + return Encoding.UTF8.GetString(stream.ToArray()); + } + + private static async Task SendMcpRequestAsync( + HttpClient client, + Uri endpoint, + string accessToken, + string? sessionId, + string? protocolVersion, + string payload, + CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + request.Headers.Add("Foundry-Features", "Toolboxes=V1Preview"); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + if (!string.IsNullOrEmpty(sessionId)) + { + request.Headers.Add("Mcp-Session-Id", sessionId); + } + if (!string.IsNullOrEmpty(protocolVersion)) + { + request.Headers.Add("MCP-Protocol-Version", protocolVersion); + } + request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); + using var requestDocument = JsonDocument.Parse(payload); + var expectedId = requestDocument.RootElement.TryGetProperty("id", out var requestId) + ? requestId.GetInt32() + : (int?)null; + + using var response = await client.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + var responsePayload = await response.Content.ReadAsStringAsync(cancellationToken); + var responseSessionId = response.Headers.TryGetValues("Mcp-Session-Id", out var values) + ? values.Single() + : sessionId; + + if (string.IsNullOrWhiteSpace(responsePayload) || expectedId is null) + { + return new(default, responseSessionId); + } + + // Streamable HTTP may return either one JSON document or SSE frames such as: + // event: message + // data: {"jsonrpc":"2.0","id":1,"result":{...}} + var responseMessages = responsePayload.TrimStart().StartsWith('{') + ? [responsePayload] + : responsePayload.Split('\n', StringSplitOptions.TrimEntries) + .Where(line => line.StartsWith("data:", StringComparison.Ordinal)) + .Select(line => line["data:".Length..].Trim()); + JsonElement? matchingResponse = null; + foreach (var responseMessage in responseMessages) + { + using var candidate = JsonDocument.Parse(responseMessage); + if (candidate.RootElement.TryGetProperty("id", out var responseId) && + responseId.ValueKind == JsonValueKind.Number && + responseId.GetInt32() == expectedId) + { + matchingResponse = candidate.RootElement.Clone(); + break; + } + } + + if (matchingResponse is null) + { + throw new InvalidOperationException( + $"The MCP response did not contain JSON-RPC response ID {expectedId}."); + } + + if (matchingResponse.Value.TryGetProperty("error", out var error)) + { + throw new InvalidOperationException($"MCP request failed: {error.GetRawText()}"); + } + + var result = matchingResponse.Value.TryGetProperty("result", out var resultElement) + ? resultElement.Clone() + : default; + return new(result, responseSessionId); + } + + private sealed record ToolboxTestResources( + Uri ProjectEndpoint, + string SearchServiceName, + string SearchResourceId); + + private sealed record ToolboxDeploymentSnapshot( + string DefaultVersion, + string ConfigurationHash, + int VersionCount, + string Description, + IReadOnlyDictionary Metadata, + IReadOnlyList Tools); + + private sealed record McpResponse(JsonElement Result, string? SessionId); + + private sealed class ToolboxFeaturesPolicy : PipelinePolicy + { + public override void Process( + PipelineMessage message, + IReadOnlyList pipeline, + int currentIndex) + { + message.Request.Headers.Add("Foundry-Features", "Toolboxes=V1Preview"); + ProcessNext(message, pipeline, currentIndex); + } + + public override ValueTask ProcessAsync( + PipelineMessage message, + IReadOnlyList pipeline, + int currentIndex) + { + message.Request.Headers.Add("Foundry-Features", "Toolboxes=V1Preview"); + return ProcessNextAsync(message, pipeline, currentIndex); + } + } + private async Task DeployFoundryHostedAgentToAzureCore(CancellationToken cancellationToken) { // Validate prerequisites diff --git a/tests/Aspire.Hosting.Foundry.Tests/FoundryToolboxReconcilerTests.cs b/tests/Aspire.Hosting.Foundry.Tests/FoundryToolboxReconcilerTests.cs new file mode 100644 index 00000000000..b25f6133233 --- /dev/null +++ b/tests/Aspire.Hosting.Foundry.Tests/FoundryToolboxReconcilerTests.cs @@ -0,0 +1,584 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Foundry.Tests; + +public class FoundryToolboxReconcilerTests +{ + [Fact] + public async Task ReconcileAsync_CreatesFirstVersion() + { + var definition = await CreateDefinitionAsync(); + var administration = new RecordingToolboxAdministration + { + VersionToCreate = "1" + }; + + var result = await new FoundryToolboxReconciler(administration) + .ReconcileAsync(definition, CancellationToken.None); + + Assert.Equal(FoundryToolboxReconcileAction.CreatedAndPromoted, result.Action); + Assert.Equal("1", result.Version); + Assert.Same(definition, Assert.Single(administration.CreatedDefinitions)); + Assert.Empty(administration.Promotions); + } + + [Fact] + public async Task ReconcileAsync_ReusesMatchingAspireManagedVersion() + { + var definition = await CreateDefinitionAsync(); + var administration = new RecordingToolboxAdministration + { + Existing = CreateExistingState(definition, "3") + }; + + var result = await new FoundryToolboxReconciler(administration) + .ReconcileAsync(definition, CancellationToken.None); + + Assert.Equal(FoundryToolboxReconcileAction.Reused, result.Action); + Assert.Equal("3", result.Version); + Assert.Empty(administration.CreatedDefinitions); + Assert.Empty(administration.Promotions); + } + + [Fact] + public async Task ReconcileAsync_RejectsMissingConsumerVersionAfterReconciliation() + { + var definition = await CreateDefinitionAsync(); + var administration = new RecordingToolboxAdministration + { + Existing = CreateExistingState(definition, "3") + }; + + var exception = await Assert.ThrowsAsync( + () => new FoundryToolboxReconciler(administration) + .ReconcileAsync(definition, "2", CancellationToken.None)); + + Assert.Contains("does not contain version '2'", exception.Message, StringComparison.Ordinal); + Assert.Empty(administration.CreatedDefinitions); + Assert.Empty(administration.Promotions); + } + + [Fact] + public async Task ReconcileAsync_RejectsDefaultChangedBeforeReportingReuse() + { + var definition = await CreateDefinitionAsync(); + var initial = CreateExistingState(definition, "3"); + var concurrentlyChanged = new FoundryToolboxState( + "4", + [ + initial.Default, + CreateVersionState("4", "other-deployment") + ]); + var administration = new RecordingToolboxAdministration(); + administration.GetResults.Enqueue(initial); + administration.GetResults.Enqueue(concurrentlyChanged); + + var exception = await Assert.ThrowsAsync( + () => new FoundryToolboxReconciler(administration) + .ReconcileAsync(definition, CancellationToken.None)); + + Assert.Contains("changed concurrently", exception.Message, StringComparison.Ordinal); + Assert.Contains( + "version '4' with a different configuration is now the default", + exception.Message, + StringComparison.Ordinal); + Assert.Empty(administration.CreatedDefinitions); + Assert.Empty(administration.Promotions); + } + + [Fact] + public async Task ReconcileAsync_CreatesAndPromotesChangedAspireManagedVersion() + { + var definition = await CreateDefinitionAsync(); + var administration = new RecordingToolboxAdministration + { + Existing = new FoundryToolboxState( + "3", + [ + CreateVersionState("3", "outdated") + ]), + VersionToCreate = "4" + }; + + var result = await new FoundryToolboxReconciler(administration) + .ReconcileAsync(definition, CancellationToken.None); + + Assert.Equal(FoundryToolboxReconcileAction.CreatedAndPromoted, result.Action); + Assert.Equal("4", result.Version); + Assert.Same(definition, Assert.Single(administration.CreatedDefinitions)); + Assert.Equal(("field-tools", "4"), Assert.Single(administration.Promotions)); + } + + [Fact] + public async Task ReconcileAsync_RejectsExistingToolboxNotManagedByAspire() + { + var definition = await CreateDefinitionAsync(); + var administration = new RecordingToolboxAdministration + { + Existing = new FoundryToolboxState( + "1", + [ + new FoundryToolboxVersionState("1", new Dictionary()) + ]) + }; + + var exception = await Assert.ThrowsAsync( + () => new FoundryToolboxReconciler(administration) + .ReconcileAsync(definition, CancellationToken.None)); + + Assert.Contains("not managed by Aspire", exception.Message, StringComparison.Ordinal); + Assert.Empty(administration.CreatedDefinitions); + Assert.Empty(administration.Promotions); + } + + [Fact] + public async Task ReconcileAsync_PromotesMatchingExistingVersion() + { + var definition = await CreateDefinitionAsync(); + var administration = new RecordingToolboxAdministration + { + Existing = new FoundryToolboxState( + "3", + [ + CreateVersionState("3", "outdated"), + CreateVersionState("2", definition.ConfigurationHash) + ]) + }; + + var result = await new FoundryToolboxReconciler(administration) + .ReconcileAsync(definition, CancellationToken.None); + + Assert.Equal(FoundryToolboxReconcileAction.Promoted, result.Action); + Assert.Equal("2", result.Version); + Assert.Empty(administration.CreatedDefinitions); + Assert.Equal(("field-tools", "2"), Assert.Single(administration.Promotions)); + } + + [Fact] + public async Task ReconcileAsync_RejectsForeignToolboxCreatedDuringInitialCreate() + { + var definition = await CreateDefinitionAsync(); + var administration = new RecordingToolboxAdministration + { + VersionToCreate = "2", + StateAfterCreate = created => new FoundryToolboxState( + "1", + [ + new FoundryToolboxVersionState("1", new Dictionary()), + new FoundryToolboxVersionState( + created, + new Dictionary(definition.CreateDeploymentMetadata())) + ]) + }; + + var exception = await Assert.ThrowsAsync( + () => new FoundryToolboxReconciler(administration) + .ReconcileAsync(definition, CancellationToken.None)); + + Assert.Contains("changed concurrently", exception.Message, StringComparison.Ordinal); + Assert.Contains("not managed by Aspire", exception.Message, StringComparison.Ordinal); + Assert.Empty(administration.Promotions); + } + + [Fact] + public async Task ReconcileAsync_RejectsForeignDefaultBeforePromotingReusableVersion() + { + var definition = await CreateDefinitionAsync(); + var initial = new FoundryToolboxState( + "3", + [ + CreateVersionState("3", "outdated"), + CreateVersionState("2", definition.ConfigurationHash) + ]); + var concurrentlyChanged = new FoundryToolboxState( + "4", + [ + new FoundryToolboxVersionState("4", new Dictionary()), + CreateVersionState("2", definition.ConfigurationHash) + ]); + var administration = new RecordingToolboxAdministration + { + Existing = initial + }; + administration.GetResults.Enqueue(initial); + administration.GetResults.Enqueue(concurrentlyChanged); + + var exception = await Assert.ThrowsAsync( + () => new FoundryToolboxReconciler(administration) + .ReconcileAsync(definition, CancellationToken.None)); + + Assert.Contains("changed concurrently", exception.Message, StringComparison.Ordinal); + Assert.Empty(administration.Promotions); + } + + [Fact] + public async Task ReconcileAsync_RejectsManagedDefaultChangedBeforePromotion() + { + var definition = await CreateDefinitionAsync(); + var initial = new FoundryToolboxState( + "3", + [ + CreateVersionState("3", "outdated"), + CreateVersionState("2", definition.ConfigurationHash) + ]); + var concurrentlyChanged = new FoundryToolboxState( + "5", + [ + .. initial.Versions, + CreateVersionState("5", "other-deployment") + ]); + var administration = new RecordingToolboxAdministration(); + administration.GetResults.Enqueue(initial); + administration.GetResults.Enqueue(concurrentlyChanged); + + var exception = await Assert.ThrowsAsync( + () => new FoundryToolboxReconciler(administration) + .ReconcileAsync(definition, CancellationToken.None)); + + Assert.Contains("changed concurrently", exception.Message, StringComparison.Ordinal); + Assert.Contains( + "expected default version '3', but version '5' is now the default", + exception.Message, + StringComparison.Ordinal); + Assert.Empty(administration.Promotions); + } + + [Fact] + public async Task ReconcileAsync_RejectsManagedToolboxCreatedConcurrently() + { + var definition = await CreateDefinitionAsync(); + var administration = new RecordingToolboxAdministration + { + VersionToCreate = "2", + StateAfterCreate = created => new FoundryToolboxState( + "1", + [ + CreateVersionState("1", "other-deployment"), + CreateVersionState(created, definition.ConfigurationHash) + ]) + }; + + var exception = await Assert.ThrowsAsync( + () => new FoundryToolboxReconciler(administration) + .ReconcileAsync(definition, CancellationToken.None)); + + Assert.Contains("changed concurrently", exception.Message, StringComparison.Ordinal); + Assert.Contains( + "expected no default version, but version '1' is now the default", + exception.Message, + StringComparison.Ordinal); + Assert.Empty(administration.Promotions); + } + + [Fact] + public async Task ReconcileAsync_VerifiesPromotedVersionRemainsDefault() + { + var definition = await CreateDefinitionAsync(); + var initial = new FoundryToolboxState( + "3", + [ + CreateVersionState("3", "outdated"), + CreateVersionState("2", definition.ConfigurationHash) + ]); + var administration = new RecordingToolboxAdministration + { + Existing = initial, + StateAfterPromotion = (_, _) => initial + }; + + var exception = await Assert.ThrowsAsync( + () => new FoundryToolboxReconciler(administration) + .ReconcileAsync(definition, CancellationToken.None)); + + Assert.Contains("changed concurrently", exception.Message, StringComparison.Ordinal); + Assert.Contains("version '3' is now the default", exception.Message, StringComparison.Ordinal); + Assert.Equal(("field-tools", "2"), Assert.Single(administration.Promotions)); + } + + [Fact] + public async Task Create_ProducesStableConfigurationHash() + { + var firstTool = await new FoundryToolboxWebSearchToolDefinition("first").ResolveAsync(CancellationToken.None); + var secondTool = await new FoundryToolboxWebSearchToolDefinition("second").ResolveAsync(CancellationToken.None); + + var first = FoundryToolboxDeploymentDefinition.Create( + "field-tools", + "Description", + [firstTool, secondTool], + new Dictionary + { + ["b"] = "2", + ["a"] = "1" + }); + var second = FoundryToolboxDeploymentDefinition.Create( + "field-tools", + "Description", + [secondTool, firstTool], + new Dictionary + { + ["a"] = "1", + ["b"] = "2" + }); + + Assert.Equal(first.ConfigurationHash, second.ConfigurationHash); + } + + [Fact] + public async Task Create_ToolDescriptionAndApprovalParticipateInConfigurationHash() + { + var baselineWeb = await new FoundryToolboxWebSearchToolDefinition("web") + .ResolveAsync(CancellationToken.None); + var describedWeb = await new FoundryToolboxWebSearchToolDefinition("web", "Search the web.") + .ResolveAsync(CancellationToken.None); + var baselineMcp = await new FoundryToolboxMcpToolDefinition( + "inventory", + ReferenceExpression.Create($"https://inventory.example.com/mcp")) + .ResolveAsync(CancellationToken.None); + var approvalMcp = await new FoundryToolboxMcpToolDefinition( + "inventory", + ReferenceExpression.Create($"https://inventory.example.com/mcp"), + new FoundryToolboxMcpToolOptions + { + ApprovalPolicy = new() + { + Global = FoundryToolboxMcpGlobalApprovalMode.Always + } + }) + .ResolveAsync(CancellationToken.None); + + var baselineWebHash = CreateDefinition([baselineWeb]).ConfigurationHash; + var describedWebHash = CreateDefinition([describedWeb]).ConfigurationHash; + var baselineMcpHash = CreateDefinition([baselineMcp]).ConfigurationHash; + var approvalMcpHash = CreateDefinition([approvalMcp]).ConfigurationHash; + + Assert.NotEqual(baselineWebHash, describedWebHash); + Assert.NotEqual(baselineMcpHash, approvalMcpHash); + } + + [Fact] + public async Task Create_RejectsDuplicateToolNames() + { + var first = await new FoundryToolboxWebSearchToolDefinition("search").ResolveAsync(CancellationToken.None); + var second = await new FoundryToolboxWebSearchToolDefinition("search").ResolveAsync(CancellationToken.None); + + var exception = Assert.Throws( + () => FoundryToolboxDeploymentDefinition.Create( + "field-tools", + "Description", + [first, second], + new Dictionary())); + + Assert.Contains("duplicate tool names", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task Create_RejectsDuplicateMcpServerLabels() + { + var first = await new FoundryToolboxMcpToolDefinition( + "first", + ReferenceExpression.Create($"https://first.example.com/mcp"), + new FoundryToolboxMcpToolOptions { ServerLabel = "shared" }) + .ResolveAsync(CancellationToken.None); + var second = await new FoundryToolboxMcpToolDefinition( + "second", + ReferenceExpression.Create($"https://second.example.com/mcp"), + new FoundryToolboxMcpToolOptions { ServerLabel = "shared" }) + .ResolveAsync(CancellationToken.None); + + var exception = Assert.Throws( + () => CreateDefinition([first, second])); + + Assert.Contains("duplicate MCP server labels: shared", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task Create_AcceptsThirteenUserMetadataEntries() + { + var tool = await new FoundryToolboxWebSearchToolDefinition("search").ResolveAsync(CancellationToken.None); + var metadata = Enumerable.Range(1, 13).ToDictionary(index => $"key-{index}", index => $"{index}"); + + var definition = FoundryToolboxDeploymentDefinition.Create( + "field-tools", + "Description", + [tool], + metadata); + + Assert.Equal(16, definition.CreateDeploymentMetadata().Count); + } + + [Fact] + public async Task Create_RejectsFourteenUserMetadataEntries() + { + var tool = await new FoundryToolboxWebSearchToolDefinition("search").ResolveAsync(CancellationToken.None); + var metadata = Enumerable.Range(1, 14).ToDictionary(index => $"key-{index}", index => $"{index}"); + + var exception = Assert.Throws( + () => FoundryToolboxDeploymentDefinition.Create( + "field-tools", + "Description", + [tool], + metadata)); + + Assert.Contains("at most 13 user metadata entries", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ValidateExistingAsync_UsesDefaultVersionWithoutOwnershipRequirements() + { + var administration = new RecordingToolboxAdministration + { + Existing = new FoundryToolboxState( + "3", + [ + new FoundryToolboxVersionState("3", new Dictionary()) + ]) + }; + + var version = await new FoundryToolboxExistingResourceValidator(administration) + .ValidateAsync("field-tools", version: null, CancellationToken.None); + + Assert.Equal("3", version); + Assert.Empty(administration.CreatedDefinitions); + Assert.Empty(administration.Promotions); + } + + [Fact] + public async Task ValidateExistingAsync_UsesPinnedVersion() + { + var administration = new RecordingToolboxAdministration + { + Existing = new FoundryToolboxState( + "3", + [ + new FoundryToolboxVersionState("2", new Dictionary()), + new FoundryToolboxVersionState("3", new Dictionary()) + ]) + }; + + var version = await new FoundryToolboxExistingResourceValidator(administration) + .ValidateAsync("field-tools", "2", CancellationToken.None); + + Assert.Equal("2", version); + } + + [Fact] + public async Task ValidateExistingAsync_RejectsMissingToolbox() + { + var exception = await Assert.ThrowsAsync( + () => new FoundryToolboxExistingResourceValidator(new RecordingToolboxAdministration()) + .ValidateAsync("field-tools", version: null, CancellationToken.None)); + + Assert.Contains("does not exist", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ValidateExistingAsync_RejectsMissingPinnedVersion() + { + var administration = new RecordingToolboxAdministration + { + Existing = new FoundryToolboxState( + "3", + [ + new FoundryToolboxVersionState("3", new Dictionary()) + ]) + }; + + var exception = await Assert.ThrowsAsync( + () => new FoundryToolboxExistingResourceValidator(administration) + .ValidateAsync("field-tools", "2", CancellationToken.None)); + + Assert.Contains("does not contain version '2'", exception.Message, StringComparison.Ordinal); + } + + private static async Task CreateDefinitionAsync() + { + var tool = await new FoundryToolboxWebSearchToolDefinition("web-search") + .ResolveAsync(CancellationToken.None); + + return FoundryToolboxDeploymentDefinition.Create( + "field-tools", + "Description", + [tool], + new Dictionary()); + } + + private static FoundryToolboxDeploymentDefinition CreateDefinition( + IReadOnlyList tools) => + FoundryToolboxDeploymentDefinition.Create( + "field-tools", + "Description", + tools, + new Dictionary()); + + private static FoundryToolboxState CreateExistingState( + FoundryToolboxDeploymentDefinition definition, + string version) => + new( + version, + [ + CreateVersionState(version, definition.ConfigurationHash) + ]); + + private static FoundryToolboxVersionState CreateVersionState(string version, string hash) => + new( + version, + new Dictionary + { + [FoundryToolboxDeploymentDefinition.ManagedByMetadataKey] = + FoundryToolboxDeploymentDefinition.ManagedByMetadataValue, + [FoundryToolboxDeploymentDefinition.ConfigurationHashMetadataKey] = hash + }); + + private sealed class RecordingToolboxAdministration : IFoundryToolboxAdministration + { + public FoundryToolboxState? Existing { get; set; } + + public string VersionToCreate { get; init; } = "1"; + + public Func? StateAfterCreate { get; init; } + + public Func? StateAfterPromotion { get; init; } + + public Queue GetResults { get; } = []; + + public List CreatedDefinitions { get; } = []; + + public List<(string Name, string Version)> Promotions { get; } = []; + + public Task GetAsync(string name, CancellationToken cancellationToken) => + Task.FromResult(GetResults.TryDequeue(out var state) ? state : Existing); + + public Task CreateVersionAsync( + FoundryToolboxDeploymentDefinition definition, + CancellationToken cancellationToken) + { + CreatedDefinitions.Add(definition); + Existing = StateAfterCreate?.Invoke(VersionToCreate) ?? + new FoundryToolboxState( + Existing?.DefaultVersion ?? VersionToCreate, + [ + .. (Existing?.Versions ?? []), + new FoundryToolboxVersionState( + VersionToCreate, + new Dictionary(definition.CreateDeploymentMetadata())) + ]); + return Task.FromResult(VersionToCreate); + } + + public Task PromoteVersionAsync( + string name, + string version, + CancellationToken cancellationToken) + { + Promotions.Add((name, version)); + Existing = StateAfterPromotion?.Invoke(name, version) ?? + (Existing is { } existing + ? new FoundryToolboxState(version, existing.Versions) + : Existing); + return Task.CompletedTask; + } + } +} diff --git a/tests/Aspire.Hosting.Foundry.Tests/Helpers/SequenceHttpMessageHandler.cs b/tests/Aspire.Hosting.Foundry.Tests/Helpers/SequenceHttpMessageHandler.cs new file mode 100644 index 00000000000..97a39c2b4bb --- /dev/null +++ b/tests/Aspire.Hosting.Foundry.Tests/Helpers/SequenceHttpMessageHandler.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Foundry.Tests; + +internal sealed class SequenceHttpMessageHandler(params HttpResponseMessage[] responses) : HttpMessageHandler +{ + private readonly Queue _responses = new(responses); + + public List Requests { get; } = []; + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Requests.Add(new( + await request.Content!.ReadAsStringAsync(cancellationToken), + request.Headers.TryGetValues("Mcp-Session-Id", out var sessionIds) + ? sessionIds.Single() + : null, + request.Headers.TryGetValues("MCP-Protocol-Version", out var protocolVersions) + ? protocolVersions.Single() + : null)); + + return _responses.Dequeue(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + while (_responses.TryDequeue(out var response)) + { + response.Dispose(); + } + } + + base.Dispose(disposing); + } + + internal sealed record HttpRequestSnapshot( + string Content, + string? SessionId, + string? ProtocolVersion); +} diff --git a/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs b/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs index dafc79fb5a9..bada6a31f17 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs @@ -720,7 +720,7 @@ private static void SetFoundryProjectOutputs(AzureCognitiveServicesProjectResour } [Fact] - public void AsHostedAgent_StampsReferenceRoleAssignmentAnnotationOnTarget_WithAzureAIUserRole() + public void AsHostedAgent_StampsReferenceRoleAssignmentAnnotationOnTarget_WithFoundryUserRole() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); var project = builder.AddFoundry("account") @@ -736,12 +736,12 @@ public void AsHostedAgent_StampsReferenceRoleAssignmentAnnotationOnTarget_WithAz var annotation = Assert.Single(hostedAgent.Target.Annotations.OfType()); Assert.Same(account, annotation.Target); Assert.Contains(annotation.Roles, role => - string.Equals(role.Id, AzureHostedAgentResource.AzureAIUserRoleDefinitionId, StringComparison.OrdinalIgnoreCase)); + string.Equals(role.Id, FoundryResource.FoundryUserRoleDefinitionId, StringComparison.OrdinalIgnoreCase)); #pragma warning restore ASPIREAZURE003 } [Fact] - public void AsHostedAgent_ReferenceRoleAssignmentAnnotation_GrantsOnlyAzureAIUserRole() + public void AsHostedAgent_ReferenceRoleAssignmentAnnotation_GrantsOnlyFoundryUserRole() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); var project = builder.AddFoundry("account") @@ -757,9 +757,9 @@ public void AsHostedAgent_ReferenceRoleAssignmentAnnotation_GrantsOnlyAzureAIUse #pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. var annotation = Assert.Single(hostedAgent.Target.Annotations.OfType()); - // The implied grant is least-privilege: only "Azure AI User" is required to invoke the agent. + // The implied grant is least-privilege: only "Foundry User" is required to invoke the agent. var role = Assert.Single(annotation.Roles); - Assert.Equal(AzureHostedAgentResource.AzureAIUserRoleDefinitionId, role.Id, ignoreCase: true); + Assert.Equal(FoundryResource.FoundryUserRoleDefinitionId, role.Id, ignoreCase: true); // The account's default data-plane roles must NOT be folded in here. A consumer that references // the account directly still receives them via the preparer's normal walk, and a consumer that diff --git a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs new file mode 100644 index 00000000000..ba10b3dcba8 --- /dev/null +++ b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs @@ -0,0 +1,956 @@ +// 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(toolbox.Resource); + var parentRelationship = Assert.Single( + toolbox.Resource.Annotations.OfType()); + 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()); + 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(tool); + Assert.Equal("web-search", webSearch.Name); + }, + tool => + { + var mcp = Assert.IsType(tool); + Assert.Equal("inventory", mcp.Name); + Assert.Equal("https://inventory.example.com/mcp", mcp.EndpointExpression.ValueExpression); + }, + tool => + { + var aiSearch = Assert.IsType(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()); + + // 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.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()); + + 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()); + + 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(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(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(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( + 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( + 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( + () => 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( + () => 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( + 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( + 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( + 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( + 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( + () => 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( + () => 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( + () => 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( + Assert.Single(firstToolbox.Resource.Tools)); + var secondDefinition = Assert.IsType( + 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( + 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( + () => 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(); + 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()); + 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()); + 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(); + + var toolboxStepAnnotation = Assert.Single(toolbox.Resource.Annotations.OfType()); + 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()); + 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(); + var toolboxStepAnnotation = Assert.Single(toolbox.Resource.Annotations.OfType()); + 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()); + 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(); + await notifications.PublishUpdateAsync(mcp.Resource, snapshot => snapshot with + { + State = KnownResourceStates.FailedToStart + }); + + var exception = await Assert.ThrowsAsync( + () => 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(); + 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") + }; +} diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts index d5e4cd3c3ff..309d1fffc43 100644 --- a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts @@ -1,4 +1,4 @@ -import { AzureContainerRegistryRole, FoundryModels, FoundryRole, HostedAgentProtocol, type FoundryModel, createBuilder } from './.aspire/modules/aspire.mjs'; +import { AzureContainerRegistryRole, FoundryModels, FoundryRole, FoundryToolboxMcpGlobalApprovalMode, HostedAgentProtocol, type FoundryModel, createBuilder } from './.aspire/modules/aspire.mjs'; const builder = await createBuilder(); @@ -27,6 +27,8 @@ const appInsights = await builder.addAzureApplicationInsights('insights'); const cosmos = await builder.addAzureCosmosDB('cosmos'); const storage = await builder.addAzureStorage('storage'); const search = await builder.addAzureSearch('search'); +const api = await builder.addContainer('api', 'nginx') + .withHttpEndpoint(); const project = await foundry.addProject('project'); await project.withContainerRegistry(registry); @@ -79,6 +81,41 @@ await _promptAgent.withTool(fabric); await _promptAgent.withTool(azFunc); await _promptAgent.withTool(funcTool); +// Foundry Toolbox +const toolbox = await project.addToolbox('field-tools', { version: '7' }); +await toolbox.withDescription('Tools for field technicians.'); +await toolbox.withWebSearchTool({ + name: 'web-search', + description: 'Search the public web.' +}); +await toolbox.withMcpTool('inventory', 'https://inventory.example.com/mcp', { + serverLabel: 'inventory-server', + serverDescription: 'Inventory MCP server.', + approvalPolicy: { + global: FoundryToolboxMcpGlobalApprovalMode.Always + } +}); +await toolbox.withMcpTool('inventory-custom', 'https://inventory.example.com/mcp', { + approvalPolicy: { + always: { + toolNames: ['delete-item'], + readOnly: false + }, + never: { + toolNames: ['get-item'], + readOnly: true + } + } +}); +await toolbox.withAISearchTool('knowledge-base', search, 'docs'); + +const existingInRun = await project.addToolbox('existing-in-run'); +await existingInRun.runAsExisting(); +const existingInPublish = await project.addToolbox('existing-in-publish'); +await existingInPublish.publishAsExisting(); +const alwaysExisting = await project.addToolbox('always-existing', { version: '3' }); +await alwaysExisting.asExisting(); + const builderProjectFoundry = await builder.addFoundry('builder-project-foundry'); const builderProject = await builderProjectFoundry.addProject('builder-project'); const _builderProjectModel = await builderProject.addModelDeployment('builder-project-model', 'Phi-4-mini', { modelVersion: '1', format: 'Microsoft' }); @@ -113,6 +150,7 @@ const hostedAgent = await builder.addExecutable( '.', ['-e', hostedAgentScript]); +await hostedAgent.withReference(toolbox); await hostedAgent.asHostedAgent(project, { description: 'Validation hosted agent', cpu: 1, @@ -131,7 +169,6 @@ const hostedAgentWithProtocol = await builder.addExecutable( await hostedAgentWithProtocol.withHttpEndpoint({ targetPort: 8089 }); await hostedAgentWithProtocol.asHostedAgentWithProtocol(project, HostedAgentProtocol.Invocations, '1.0.0'); -const api = await builder.addContainer('api', 'nginx'); await foundry.withContainerRegistryRoleAssignments(registry, [AzureContainerRegistryRole.AcrPull]); await api.withFoundryRoleAssignments(foundry, [FoundryRole.CognitiveServicesOpenAIUser]); diff --git a/tests/Shared/Hex1bAutomatorTestHelpers.cs b/tests/Shared/Hex1bAutomatorTestHelpers.cs index 932bde141f2..2ec95936340 100644 --- a/tests/Shared/Hex1bAutomatorTestHelpers.cs +++ b/tests/Shared/Hex1bAutomatorTestHelpers.cs @@ -725,7 +725,8 @@ await auto.WaitUntilAsync(s => /// internal static async Task WaitForPipelineSuccessAsync( this Hex1bTerminalAutomator auto, - TimeSpan? timeout = null) + TimeSpan? timeout = null, + SequenceCounter? counter = null) { var effectiveTimeout = timeout ?? TimeSpan.FromMinutes(5); var pipelineSucceeded = false; @@ -733,6 +734,19 @@ internal static async Task WaitForPipelineSuccessAsync( await auto.WaitUntilAsync(s => { + if (counter is not null) + { + var errorSearcher = new CellPatternSearcher() + .FindPattern(counter.Value.ToString()) + .RightText(" ERR:"); + + if (errorSearcher.Search(s).Count > 0) + { + terminalOutput = s.GetText(); + return true; + } + } + if (s.ContainsText(ConsoleActivityLoggerStrings.PipelineFailed)) { terminalOutput = s.GetText(); @@ -746,7 +760,9 @@ await auto.WaitUntilAsync(s => } return false; - }, timeout: effectiveTimeout, description: "pipeline succeeded or failed"); + }, timeout: effectiveTimeout, description: counter is null + ? "pipeline succeeded or failed" + : $"pipeline succeeded, failed, or error prompt [{counter.Value} ERR:*] $"); if (!pipelineSucceeded) {