Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
7228774
Add Foundry Toolbox resource and deploy pipeline
davidfowl May 31, 2026
4f559cd
[debug] dump env var state to diagnose CI-only Toolbox test failure
davidfowl May 31, 2026
6a29a10
Seed APPLICATION_INSIGHTS_CONNECTION_STRING output in toolbox resolut…
davidfowl May 31, 2026
94d21a6
Add MCP auth/headers and auto-derived publish deps for FoundryToolbox
davidfowl May 31, 2026
65205f1
Add TS Foundry Toolbox + auth MCP playground sample
davidfowl May 31, 2026
35cc422
Bypass ModelReaderWriter.Write for OpenAI Responses tools in polyglot…
davidfowl Jun 1, 2026
454e116
Flesh out TS Foundry Toolbox sample with dev tunnel + publish-mode to…
davidfowl Jun 1, 2026
9a817a3
Merge main into Foundry toolbox work
tommasodotNET Sep 1, 2026
76ccaee
Harden Foundry Toolbox deployment
tommasodotNET Sep 1, 2026
f4393da
Address Foundry Toolbox review feedback
tommasodotNET Sep 1, 2026
c7f7b40
Complete Toolbox review coverage
tommasodotNET Sep 1, 2026
3a18d86
Address Toolbox endpoint review feedback
tommasodotNET Sep 1, 2026
2293d2c
Harden Toolbox MCP endpoint validation
tommasodotNET Sep 1, 2026
b6eb4c3
Complete Foundry Toolbox lifecycle support
tommasodotNET Sep 2, 2026
017cc0a
Support complete MCP approval filters
tommasodotNET Sep 2, 2026
856c29c
Fix Toolbox Search identity grants
tommasodotNET Sep 2, 2026
5dfcf40
Prevent concurrent Toolbox default overwrite
tommasodotNET Sep 2, 2026
5954b52
Retry Toolbox tool discovery
tommasodotNET Sep 2, 2026
93975b4
Complete Toolbox reconciliation safeguards
tommasodotNET Sep 2, 2026
db5dd82
Fix Toolbox consumer lifecycle and access
tommasodotNET Sep 2, 2026
84f1280
Wait for Toolbox tool readiness
tommasodotNET Sep 2, 2026
dc43fa9
Harden Toolbox discovery retries
tommasodotNET Sep 2, 2026
09a4a7f
Fail Toolbox on terminal MCP dependencies
tommasodotNET Sep 2, 2026
99e82a0
Validate Toolbox consumer version pins
tommasodotNET Sep 2, 2026
8638f81
Probe the reconciled Toolbox version
tommasodotNET Sep 2, 2026
f217fef
Complete Toolbox tool metadata
tommasodotNET Sep 2, 2026
bb1529c
Follow Toolbox discovery pagination
tommasodotNET Sep 2, 2026
5e908f5
Clarify Toolbox Search index prerequisite
tommasodotNET Sep 2, 2026
6e4ac4d
Allow Search RBAC propagation
tommasodotNET Sep 2, 2026
7205ea9
Require every Toolbox MCP server
tommasodotNET Sep 2, 2026
7d4af08
Address Toolbox review feedback
tommasodotNET Sep 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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();
2 changes: 2 additions & 0 deletions src/Aspire.Hosting.Foundry/FoundryResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ namespace Aspire.Hosting.Foundry;
public class FoundryResource(string name, Action<AzureResourceInfrastructure> 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<FoundryDeploymentResource> _deployments = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,6 @@ namespace Aspire.Hosting.Foundry;
/// </summary>
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";

/// <summary>
Expand Down Expand Up @@ -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}");
Comment thread
tommasodotNET marked this conversation as resolved.

var assignmentName = StableGuid(principalId, roleDefinitionId.ToString(), foundryResourceId);

Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -533,25 +533,25 @@ private static void ConfigurePublishMode<T>(
// 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);
}

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<RoleDefinition>
{
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.
Expand Down
85 changes: 69 additions & 16 deletions src/Aspire.Hosting.Foundry/Project/ConnectionBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -34,6 +35,13 @@ public static IResourceBuilder<AzureCognitiveServicesProjectConnectionResource>
this IResourceBuilder<AzureCognitiveServicesProjectResource> builder,
[ResourceName] string name,
Func<AzureResourceInfrastructure, CognitiveServicesConnectionProperties> configureProperties)
=> AddConnection(builder, name, configureProperties, configureAdditionalInfrastructure: null);

private static IResourceBuilder<AzureCognitiveServicesProjectConnectionResource> AddConnection(
this IResourceBuilder<AzureCognitiveServicesProjectResource> builder,
string name,
Func<AzureResourceInfrastructure, CognitiveServicesConnectionProperties> configureProperties,
Action<AzureResourceInfrastructure, CognitiveServicesProject>? configureAdditionalInfrastructure)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
Expand Down Expand Up @@ -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 });
}
Expand Down Expand Up @@ -203,25 +212,51 @@ public static IResourceBuilder<AzureCognitiveServicesProjectConnectionResource>
public static IResourceBuilder<AzureCognitiveServicesProjectConnectionResource> AddConnection(
this IResourceBuilder<AzureCognitiveServicesProjectResource> builder,
AzureSearchResource search)
=> builder.AddSearchConnection($"connection-{Guid.NewGuid():N}", search);

internal static IResourceBuilder<AzureCognitiveServicesProjectConnectionResource> AddSearchConnection(
this IResourceBuilder<AzureCognitiveServicesProjectResource> 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);
});
}

/// <summary>
Expand All @@ -232,12 +267,30 @@ public static IResourceBuilder<AzureCognitiveServicesProjectConnectionResource>
this IResourceBuilder<AzureCognitiveServicesProjectResource> builder,
IResourceBuilder<AzureSearchResource> search)
{
builder.WithRoleAssignments(search,
SearchBuiltInRole.SearchIndexDataReader,
SearchBuiltInRole.SearchServiceContributor);
return builder.AddConnection(search.Resource);
}

private static void AddSearchRoleAssignment(
AzureResourceInfrastructure infrastructure,
SearchService searchService,
CognitiveServicesProject project,
BicepValue<Guid> 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(
Comment thread
tommasodotNET marked this conversation as resolved.
searchService.Id,
project.Id,
roleAssignment.RoleDefinitionId);
infrastructure.Add(roleAssignment);
}

/// <summary>
/// Adds a Key Vault connection to the Microsoft Foundry project.
/// </summary>
Expand Down
138 changes: 138 additions & 0 deletions src/Aspire.Hosting.Foundry/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,20 @@ The Microsoft Foundry project resource exposes the following connection properti
| `ConnectionString` | The connection string, with the format `Endpoint=<uri>` |
| `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
Expand Down Expand Up @@ -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
Comment thread
tommasodotNET marked this conversation as resolved.

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<Projects.MyService>("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.
Expand Down Expand Up @@ -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/

Expand Down
Loading
Loading