From 72287749adc315955507231838394f652f3a0f9c Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 30 May 2026 18:26:59 -0700 Subject: [PATCH 01/30] Add Foundry Toolbox resource and deploy pipeline Adds first-class Microsoft Foundry Toolbox support to Aspire.Hosting.Foundry. Toolboxes are a Foundry data-plane construct (no ARM/Bicep) that bundle tools for agents. Aspire now models them as a resource that is created via the AgentToolboxes data plane API at deploy time, after the parent project's endpoint becomes ready. Public API: - FoundryToolboxResource (parented to AzureCognitiveServicesProjectResource, implements IResourceWithConnectionString) - AddToolbox extension on the project resource builder - WithWebSearchTool, WithMcpTool, WithAISearchTool tool-definition extensions Deploy is wired as a PipelineStepAnnotation so it runs alongside Azure ARM provisioning: a publish-mode deploy step and a run-mode before-start step that both call AgentToolboxes.CreateToolboxVersionAsync once the project endpoint is reachable. Retries with Polly handle the brief window where the freshly provisioned project endpoint is still warming up. Polyglot (TypeScript): AddToolboxForPolyglot and WithMcpToolForPolyglot use AspireExport/AspireUnion so the TS apphost can call addToolbox, withMcpTool, withWebSearchTool, and withAISearchTool with TS-friendly union types. Validated end-to-end against a real Azure AI Foundry account: the deploy hook fired in 3.4s after the project reached Running, and the toolbox was confirmed present on the data plane (toolboxes API returned field-tools, default_version 1). 102/102 tests pass in Aspire.Hosting.Foundry.Tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../FoundryAgentBasic.AppHost/AppHost.cs | 5 + .../FoundryToolboxBuilderExtensions.cs | 186 +++++++++ .../Toolbox/FoundryToolboxOptions.cs | 17 + .../Toolbox/FoundryToolboxResource.cs | 367 ++++++++++++++++++ .../Toolbox/FoundryToolboxToolDefinition.cs | 147 +++++++ .../ToolboxTests.cs | 268 +++++++++++++ .../TypeScript/apphost.mts | 9 +- 7 files changed, 998 insertions(+), 1 deletion(-) create mode 100644 src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs create mode 100644 src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxOptions.cs create mode 100644 src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs create mode 100644 src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs create mode 100644 tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs diff --git a/playground/FoundryAgentBasic/FoundryAgentBasic.AppHost/AppHost.cs b/playground/FoundryAgentBasic/FoundryAgentBasic.AppHost/AppHost.cs index 5949d2d333c..509eb7785ac 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. The toolbox is created on the Foundry data +// plane at deploy time via AgentToolboxes.CreateToolboxVersionAsync. +project.AddToolbox("field-tools", t => t.Version = "v1") + .WithWebSearchTool(); + builder.Build().Run(); diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs new file mode 100644 index 00000000000..60e496069d8 --- /dev/null +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs @@ -0,0 +1,186 @@ +// 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; +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. + /// + /// A new immutable toolbox version is created on the Foundry data plane at deploy time. The + /// property pins the version used by consumers in + /// the MCP endpoint URI; the version produced by the most recent deploy is exposed via + /// . + /// + /// 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); + + return builder.ApplicationBuilder.AddResource(toolbox) + .WithIconName("Toolbox"); + } + + /// + /// 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); + } + + /// + /// Adds a web search tool definition to the Toolbox. + /// + /// The resource builder for the Toolbox. + /// The tool name. + /// A reference to the for chaining. + /// The resource builder. + [AspireExport] + public static IResourceBuilder WithWebSearchTool( + this IResourceBuilder builder, + string name = "web-search") + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + builder.Resource.AddTool(new FoundryToolboxWebSearchToolDefinition(name)); + + return builder; + } + + /// + /// Adds an MCP tool definition to the Toolbox. + /// + /// The resource builder for the Toolbox. + /// The tool name. + /// The MCP endpoint URI. + /// 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) + { + ArgumentException.ThrowIfNullOrEmpty(endpoint); + + return builder.WithMcpTool(name, ReferenceExpression.Create($"{endpoint}")); + } + + /// + /// Adds an MCP tool definition to the Toolbox. + /// + /// The resource builder for the Toolbox. + /// The tool name. + /// The MCP endpoint. + /// 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) + { + ArgumentNullException.ThrowIfNull(endpoint); + + return builder.WithMcpTool(name, ReferenceExpression.Create($"{endpoint}")); + } + + /// + /// Adds an MCP tool definition to the Toolbox. + /// + [AspireExport("withMcpTool")] + internal static IResourceBuilder WithMcpToolForPolyglot( + this IResourceBuilder builder, + string name, + [AspireUnion(typeof(string), typeof(EndpointReference))] object endpoint) + { + ArgumentNullException.ThrowIfNull(endpoint); + + return endpoint switch + { + string endpointString => builder.WithMcpTool(name, endpointString), + EndpointReference endpointReference => builder.WithMcpTool(name, endpointReference), + _ => throw new ArgumentException("Endpoint must be a string or endpoint reference.", 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 optional search index name. + /// A reference to the for chaining. + /// The resource builder. + [AspireExport] + public static IResourceBuilder WithAISearchTool( + this IResourceBuilder builder, + string name, + IResourceBuilder search, + string? indexName = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentNullException.ThrowIfNull(search); + + var projectBuilder = builder.ApplicationBuilder.CreateResourceBuilder(builder.Resource.Parent); + var connection = projectBuilder.AddConnection(search); + builder.Resource.AddTool(new FoundryToolboxAzureAISearchToolDefinition( + name, + search.Resource, + connection.Resource, + indexName)); + + return builder; + } + + private static IResourceBuilder WithMcpTool( + this IResourceBuilder builder, + string name, + ReferenceExpression endpointExpression) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentNullException.ThrowIfNull(endpointExpression); + + builder.Resource.AddTool(new FoundryToolboxMcpToolDefinition(name, endpointExpression)); + + 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..637230025ef --- /dev/null +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxOptions.cs @@ -0,0 +1,17 @@ +// 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; } +} diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs new file mode 100644 index 00000000000..8e99d365ddb --- /dev/null +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs @@ -0,0 +1,367 @@ +// 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.Globalization; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Azure; +using Aspire.Hosting.Pipelines; +using Azure.AI.Projects; +using Azure.AI.Projects.Agents; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Polly; +using Polly.Retry; + +namespace Aspire.Hosting.Foundry; + +/// +/// Represents a Microsoft Foundry Toolbox endpoint associated with a Foundry project. +/// +/// +/// Toolboxes are Foundry data-plane resources: there is no ARM/Bicep representation. Each call +/// to +/// creates a new immutable version of the toolbox. Aspire registers a deploy-time pipeline step +/// that resolves all tool definitions and calls the data plane API once the parent project's +/// endpoint is ready. +/// +[AspireExport(ExposeProperties = true)] +public sealed class FoundryToolboxResource : Resource, IResourceWithParent, IResourceWithConnectionString +{ + internal const string DefaultApiVersion = "v1"; + internal const string PreviewFeatureHeaderValue = "Toolboxes=V1Preview"; + internal const string AuthorizationScopeValue = "https://ai.azure.com/.default"; + + private const string BeforeStartStepName = "before-start"; + private const string RunModeAzureProvisionStepName = "run-mode-azure-provision"; + private const int ProjectEndpointReadinessMaxRetryAttempts = 11; + private static readonly TimeSpan s_projectEndpointReadinessDelay = TimeSpan.FromSeconds(5); + + private readonly List _tools = []; + + /// + /// Initializes a new instance of the class. + /// + /// The Toolbox name. + /// The parent Microsoft Foundry project resource. + /// The optional Toolbox version to reference. + public FoundryToolboxResource([ResourceName] string name, AzureCognitiveServicesProjectResource parent, string? version = null) + : base(name) + { + ArgumentNullException.ThrowIfNull(parent); + + Parent = parent; + Version = version; + + // Register pipeline steps to create a new toolbox version on the Foundry data plane. + // Mirrors AzurePromptAgentResource: a publish-mode step that always runs and a run-mode + // step that kicks off the deployment before the application starts. + Annotations.Add(new PipelineStepAnnotation(context => + { + var steps = new List(); + + if (context.PipelineContext.ExecutionContext.IsRunMode) + { + var beforeStartDeployStep = new PipelineStep + { + Name = $"deploy-{Name}-before-start", + Description = $"Deploys toolbox {Name} before the application starts.", + Action = DeployBeforeStartAsync, + RequiredBySteps = [BeforeStartStepName], + Resource = this, + DependsOnSteps = [RunModeAzureProvisionStepName] + }; + steps.Add(beforeStartDeployStep); + } + + var toolboxDeployStep = new PipelineStep + { + Name = $"deploy-{Name}", + Description = $"Deploys toolbox {Name}.", + Action = async (stepCtx) => + { + var version = await DeployAsync(Parent, stepCtx, logRetry: null, stepCtx.CancellationToken).ConfigureAwait(false); + stepCtx.ReportingStep.Log(LogLevel.Information, + new MarkdownString($"Successfully deployed **{Name}** as Foundry Toolbox (version {version.Version})")); + DeployedVersion.Set(version.Version); + }, + Tags = [WellKnownPipelineTags.DeployCompute], + RequiredBySteps = [WellKnownPipelineSteps.Deploy], + Resource = this, + DependsOnSteps = [WellKnownPipelineSteps.DeployPrereq, AzureEnvironmentResource.ProvisionInfrastructureStepName] + }; + steps.Add(toolboxDeployStep); + + return Task.FromResult>(steps); + })); + } + + /// + /// Gets the parent Microsoft Foundry project resource. + /// + public AzureCognitiveServicesProjectResource Parent { get; } + + /// + /// Gets or sets the Toolbox version to reference. When unset, the default Toolbox version is used. + /// + /// + /// This is the user-pinned version used in the MCP endpoint URI. To read the version that was + /// produced by the most recent deployment, use . + /// + 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 a description of the toolbox. Persisted as the toolbox version's description in Foundry. + /// + public string Description { get; set; } = "Foundry Toolbox"; + + /// + /// Gets the metadata to associate with the toolbox version on creation. + /// + public IDictionary Metadata { get; init; } = new Dictionary() + { + { "DeployedBy", "Aspire Hosting Framework" }, + { "DeployedOn", DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture) } + }; + + /// + /// The version produced by the most recent deployment. Populated after the deploy pipeline + /// step runs successfully; empty before that. + /// + public StaticValueProvider DeployedVersion { get; } = new(); + + /// + /// Gets the tool definitions modeled for this Toolbox. + /// + public IReadOnlyList Tools => _tools; + + /// + /// Gets the Toolbox MCP endpoint URI expression. + /// + public ReferenceExpression UriExpression => Version is { Length: > 0 } version + ? ReferenceExpression.Create($"{Parent.Endpoint}/toolboxes/{Name}/versions/{version}/mcp?api-version={ApiVersion}") + : 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) + { + _tools.Add(tool); + } + + IEnumerable> IResourceWithConnectionString.GetConnectionProperties() + { + // Each connection property maps to an env var prefixed with the toolbox resource name + // (e.g. FIELD_TOOLS_URI). This lets multiple toolboxes coexist on the same consumer without + // colliding. Apps that only consume a single toolbox should prefer the canonical Foundry + // env vars emitted by the specialized WithReference overload (FOUNDRY_AGENT_TOOLBOX_*) so + // the same code path works both locally and in the Foundry hosted-agent runtime. + 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}")); + } + } + + /// + /// Deploys the toolbox to the given Microsoft Foundry project, creating a new immutable toolbox + /// version via the data plane. + /// + private async Task DeployAsync( + AzureCognitiveServicesProjectResource project, + PipelineStepContext context, + Action? logRetry, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(project); + + var projectEndpoint = await project.Endpoint.GetValueAsync(cancellationToken).ConfigureAwait(false); + if (string.IsNullOrEmpty(projectEndpoint)) + { + throw new InvalidOperationException($"Project '{project.Name}' does not have a valid endpoint."); + } + + var tokenCredentialProvider = context.Services.GetRequiredService(); + var credential = tokenCredentialProvider.TokenCredential; + + // Resolve each tool definition to its SDK shape. + var tools = new List(_tools.Count); + foreach (var tool in _tools) + { + var agentTool = await tool.ToProjectsAgentToolAsync(cancellationToken).ConfigureAwait(false); + tools.Add(agentTool); + } + + var projectClient = new AIProjectClient(new Uri(projectEndpoint), credential); + + var retryPipeline = new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + Delay = s_projectEndpointReadinessDelay, + MaxRetryAttempts = ProjectEndpointReadinessMaxRetryAttempts, + ShouldHandle = new PredicateBuilder() + .Handle(IsProjectEndpointNotReady), + OnRetry = retry => + { + var retryMessage = $"Foundry project endpoint for '{project.Name}' is not ready yet. Retrying toolbox deployment in {s_projectEndpointReadinessDelay.TotalSeconds:n0} seconds ({retry.AttemptNumber + 1}/{ProjectEndpointReadinessMaxRetryAttempts})."; + if (logRetry is not null) + { + logRetry?.Invoke(retryMessage); + } + else + { + context.ReportingStep.Log(LogLevel.Warning, retryMessage); + } + + return ValueTask.CompletedTask; + } + }) + .Build(); + + return await retryPipeline.ExecuteAsync(async ct => + { + // Pass policies: null — this opts in to the V1Preview default policy set. The metadata + // dictionary copy guards against later mutation of Metadata on the resource between + // deploys (e.g. when a run-mode deploy redeploys after a publish-mode deploy). + var result = await projectClient.AgentAdministrationClient + .GetAgentToolboxes() + .CreateToolboxVersionAsync( + Name, + tools, + Description, + new Dictionary(Metadata), + policies: null, + cancellationToken: ct) + .ConfigureAwait(false); + + return result.Value; + }, cancellationToken).ConfigureAwait(false); + } + + private 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 Task DeployBeforeStartAsync(PipelineStepContext context) + { + if (!context.ExecutionContext.IsRunMode) + { + return Task.CompletedTask; + } + + StartRunModeDeployment(context); + + return Task.CompletedTask; + } + + private void StartRunModeDeployment(PipelineStepContext context) + { + // Fire-and-forget so the application can start before the toolbox finishes deploying. + // The notification service surfaces the toolbox state in the dashboard while the deploy + // is in flight. Matches AzurePromptAgentResource. + 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 logger = context.Services.GetRequiredService().GetLogger(this); + try + { + await notificationService.PublishUpdateAsync(this, s => s with + { + State = new("Waiting for project", KnownResourceStateStyles.Info) + }).ConfigureAwait(false); + + await WaitForProjectAndToolsAsync(notificationService, cancellationToken).ConfigureAwait(false); + + await notificationService.PublishUpdateAsync(this, s => s with + { + State = new("Deploying toolbox", KnownResourceStateStyles.Info) + }).ConfigureAwait(false); + + logger.LogInformation("Deploying toolbox '{ToolboxName}' to Foundry project '{ProjectName}'...", Name, Parent.Name); + + var version = await DeployAsync(Parent, context, message => logger.LogWarning("{Message}", message), cancellationToken).ConfigureAwait(false); + DeployedVersion.Set(version.Version); + + logger.LogInformation("Successfully deployed toolbox '{ToolboxName}' (version {Version})", Name, version.Version); + + await notificationService.PublishUpdateAsync(this, s => s with + { + State = new("Running", KnownResourceStateStyles.Success) + }).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to deploy toolbox '{ToolboxName}'", Name); + + await notificationService.PublishUpdateAsync(this, s => s with + { + State = new("Failed to deploy", KnownResourceStateStyles.Error) + }).ConfigureAwait(false); + } + } + + private async Task WaitForProjectAndToolsAsync(ResourceNotificationService notificationService, 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); + } + + // The only sub-resources we need to await are the Azure AI Search project connections + // backing AzureAISearch toolbox tools. WebSearch needs no provisioning, and MCP tools + // expose only a URL expression (see the note in FoundryToolboxMcpToolDefinition). + var toolConnectionProvisioningTasks = _tools + .Select(tool => tool switch + { + FoundryToolboxAzureAISearchToolDefinition { Connection: IAzureResource searchConnection } => searchConnection.ProvisioningTaskCompletionSource?.Task, + _ => null + }) + .OfType(); + + await Task.WhenAll(toolConnectionProvisioningTasks.Select(task => task.WaitAsync(cancellationToken))).ConfigureAwait(false); + } +} diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs new file mode 100644 index 00000000000..ba71c687aa4 --- /dev/null +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs @@ -0,0 +1,147 @@ +// 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; +using Aspire.Hosting.Azure; +using Azure.AI.Projects.Agents; +using OpenAI.Responses; + +namespace Aspire.Hosting.Foundry; + +/// +/// Base type for Microsoft Foundry Toolbox tool definitions. +/// +public abstract class FoundryToolboxToolDefinition +{ + private protected FoundryToolboxToolDefinition(string name) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + Name = name; + } + + /// + /// Gets the tool name. + /// + public string Name { get; } + + /// + /// Resolves this tool definition into the SDK shape () used by the + /// Foundry data plane when creating a new toolbox version. + /// + /// Cancellation token. + internal abstract ValueTask ToProjectsAgentToolAsync(CancellationToken cancellationToken); +} + +/// +/// Describes a web search tool in a Microsoft Foundry Toolbox. +/// +public sealed class FoundryToolboxWebSearchToolDefinition : FoundryToolboxToolDefinition +{ + internal FoundryToolboxWebSearchToolDefinition(string name) + : base(name) + { + } + + internal override ValueTask ToProjectsAgentToolAsync(CancellationToken cancellationToken) + { + // ResponseTool.CreateWebSearchTool() produces an OpenAI WebSearchTool; AsAgentTool() lifts + // it into the Azure.AI.Projects.Agents wire shape that AgentToolboxes.CreateToolboxVersion + // expects. + return new ValueTask(ResponseTool.CreateWebSearchTool().AsAgentTool()); + } +} + +/// +/// Describes an MCP tool in a Microsoft Foundry Toolbox. +/// +public sealed class FoundryToolboxMcpToolDefinition : FoundryToolboxToolDefinition +{ + internal FoundryToolboxMcpToolDefinition(string name, ReferenceExpression endpointExpression) + : base(name) + { + ArgumentNullException.ThrowIfNull(endpointExpression); + + EndpointExpression = endpointExpression; + } + + /// + /// Gets the MCP endpoint expression for the tool. + /// + public ReferenceExpression EndpointExpression { get; } + + internal override async ValueTask ToProjectsAgentToolAsync(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."); + } + + // NOTE: When the MCP endpoint points at a compute resource owned by this app (for example an + // EndpointReference from a project, container, or executable), the published URL is only + // reachable after that resource has been deployed. Toolbox creation does not currently take + // a pipeline dependency on referenced compute, so an MCP tool that points at a sibling + // resource may need a manual `WaitFor(...)` to ensure correct ordering during `aspire deploy`. + return ResponseTool.CreateMcpTool(serverLabel: Name, serverUri: new Uri(endpoint)).AsAgentTool(); + } +} + +/// +/// Describes an Azure AI Search tool in a Microsoft Foundry Toolbox. +/// +public sealed class FoundryToolboxAzureAISearchToolDefinition : FoundryToolboxToolDefinition +{ + internal FoundryToolboxAzureAISearchToolDefinition( + string name, + AzureSearchResource searchResource, + AzureCognitiveServicesProjectConnectionResource connection, + string? indexName) + : base(name) + { + ArgumentNullException.ThrowIfNull(searchResource); + ArgumentNullException.ThrowIfNull(connection); + + SearchResource = searchResource; + Connection = connection; + IndexName = indexName; + } + + /// + /// 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 optional Azure AI Search index name. + /// + public string? IndexName { get; } + + internal override async ValueTask ToProjectsAgentToolAsync(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]); + return new AzureAISearchTool(options); + } +} diff --git a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs new file mode 100644 index 00000000000..2f0ceea63d7 --- /dev/null +++ b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs @@ -0,0 +1,268 @@ +// 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 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 = "v1"); + + Assert.Equal("field-tools", toolbox.Resource.Name); + Assert.Equal("v1", toolbox.Resource.Version); + Assert.Same(project.Resource, toolbox.Resource.Parent); + } + + [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") + .WithWebSearchTool() + .WithMcpTool("inventory", "https://inventory.example.com/mcp") + .WithAISearchTool("knowledge-base", search, "docs"); + + 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.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 = "v1"); + + 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/v1/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 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 = "v1"); + + 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()); + project.Resource.Outputs["endpoint"] = "https://project.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/v1/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", t => t.Version = "v1"); + + 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", t => t.Version = "v1"); + + 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("run-mode-azure-provision", 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.ToProjectsAgentToolAsync(CancellationToken.None); + + Assert.NotNull(projectTool); + } + + [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"); + + // 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.ToProjectsAgentToolAsync(CancellationToken.None); + + 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); + } + + [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.ToProjectsAgentToolAsync(CancellationToken.None); + + Assert.NotNull(projectTool); + } + + [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.ToProjectsAgentToolAsync(CancellationToken.None)); + } + + 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); + } +} diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts index 2479a1aeba8..18b5908b11a 100644 --- a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts @@ -27,6 +27,7 @@ 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'); const project = await foundry.addProject('project'); await project.withContainerRegistry(registry); @@ -79,6 +80,12 @@ await _promptAgent.withTool(fabric); await _promptAgent.withTool(azFunc); await _promptAgent.withTool(funcTool); +// Foundry Toolbox +const toolbox = await project.addToolbox('field-tools', { version: 'v1' }); +await toolbox.withWebSearchTool(); +await toolbox.withMcpTool('inventory', api.getEndpoint('http')); +await toolbox.withAISearchTool('knowledge-base', search); + 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' }); @@ -110,6 +117,7 @@ server.listen(port, '127.0.0.1'); ` ]); +await hostedAgent.withReference(toolbox); await hostedAgent.asHostedAgent(project, { description: 'Validation hosted agent', cpu: 1, @@ -118,7 +126,6 @@ await hostedAgent.asHostedAgent(project, { environmentVariables: { VALIDATION_MODE: 'true' } }); -const api = await builder.addContainer('api', 'nginx'); await foundry.withContainerRegistryRoleAssignments(registry, [AzureContainerRegistryRole.AcrPull]); await api.withFoundryRoleAssignments(foundry, [FoundryRole.CognitiveServicesOpenAIUser]); From 4f559cd2f30eaf72d285f320ab7d3ad14202acce Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 30 May 2026 19:20:45 -0700 Subject: [PATCH 02/30] [debug] dump env var state to diagnose CI-only Toolbox test failure Temporary instrumentation in AsHostedAgent_ResolvesToolboxConnectionString to capture python app + hosted agent + project env vars and annotations into the assertion failure message. To be reverted once the CI vs local divergence is identified. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ToolboxTests.cs | 72 ++++++++++++++++--- 1 file changed, 64 insertions(+), 8 deletions(-) diff --git a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs index 2f0ceea63d7..d8eaff21cf9 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs @@ -119,15 +119,71 @@ public async Task AsHostedAgent_ResolvesToolboxConnectionString() var hostedAgent = Assert.Single(builder.Resources.OfType()); project.Resource.Outputs["endpoint"] = "https://project.example.com"; + // TEMP DEBUG: capture env var callback keys + values from python app + hosted agent + project, + // so that on failure the assertion message contains enough info to diagnose CI-only failures. + var dump = new System.Text.StringBuilder(); + dump.AppendLine("---- TEMP DEBUG DUMP ----"); + foreach (var (label, res) in new[] + { + ("python-app (agent.Resource)", (IResource)agent.Resource), + ("hosted-agent", hostedAgent), + ("project (my-project)", project.Resource) + }) + { + dump.AppendLine($"== {label} env vars =="); + if (!res.TryGetEnvironmentVariables(out var callbacks)) + { + dump.AppendLine(" (no env var callbacks)"); + continue; + } + var ctx = new EnvironmentCallbackContext(builder.ExecutionContext, res, new Dictionary()); + foreach (var cb in callbacks) + { + try + { + await cb.Callback(ctx); + } + catch (Exception ex) + { + dump.AppendLine($" (callback threw: {ex.GetType().Name}: {ex.Message})"); + } + } + dump.AppendLine($" count = {ctx.EnvironmentVariables.Count}"); + foreach (var (k, v) in ctx.EnvironmentVariables) + { + dump.AppendLine($" {k} = [{v?.GetType().FullName}] {v}"); + } + } + + // Dump pipeline steps and annotations on the python app to spot a leak via annotation sharing. + dump.AppendLine("== python-app annotations =="); + foreach (var ann in agent.Resource.Annotations) + { + dump.AppendLine($" {ann.GetType().FullName}"); + } + dump.AppendLine($"== project.Outputs keys =="); + foreach (var key in project.Resource.Outputs.Keys) + { + dump.AppendLine($" {key} = {project.Resource.Outputs[key]}"); + } + dump.AppendLine("---- END DUMP ----"); + 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/v1/mcp?api-version=v1", envVars["ConnectionStrings__field-tools"]); + try + { + var envVars = await AzureHostedAgentResource.GetResolvedEnvironmentVariablesAsync( + builder.ExecutionContext, + hostedAgent, + agent.Resource, + NullLogger.Instance, + cts.Token); + + Assert.Equal("https://project.example.com/toolboxes/field-tools/versions/v1/mcp?api-version=v1", envVars["ConnectionStrings__field-tools"]); + } + catch (Exception ex) + { + throw new Xunit.Sdk.XunitException($"Resolution failed: {ex.GetType().FullName}: {ex.Message}\n\n{dump}\n\nStack:\n{ex.StackTrace}", ex); + } } [Fact] From 6a29a1071009a45a7d07a939aedcd23560699814 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 30 May 2026 19:38:01 -0700 Subject: [PATCH 03/30] Seed APPLICATION_INSIGHTS_CONNECTION_STRING output in toolbox resolution test AsHostedAgent_ResolvesToolboxConnectionString was failing on CI (Windows + Ubuntu) with `InvalidOperationException: No output for APPLICATION_INSIGHTS_CONNECTION_STRING on resource my-project`. AzureHostedAgentResource.GetResolvedEnvironmentVariablesAsync walks every env var callback on the target resource. On CI the python app picks up extra WithReference(project)-style callbacks that splat AzureCognitiveServicesProjectResource.GetConnectionProperties() (Uri / ConnectionString / ApplicationInsightsConnectionString) as MY_PROJECT_* env vars. Those references resolve through BicepOutputReference, which throws if the named output is not seeded on the test resource. The test is only asserting on ConnectionStrings__field-tools, so unrelated extra env vars should not break it. Seed APPLICATION_INSIGHTS_CONNECTION_STRING (in addition to the existing endpoint output) so every project output reachable through GetConnectionProperties() can be resolved. Also drops the temporary diagnostic dump that was wrapped around the assertion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ToolboxTests.cs | 78 ++++--------------- 1 file changed, 14 insertions(+), 64 deletions(-) diff --git a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs index d8eaff21cf9..fa3f3994083 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs @@ -117,73 +117,23 @@ public async Task AsHostedAgent_ResolvesToolboxConnectionString() using var app = builder.Build(); var hostedAgent = Assert.Single(builder.Resources.OfType()); - project.Resource.Outputs["endpoint"] = "https://project.example.com"; - - // TEMP DEBUG: capture env var callback keys + values from python app + hosted agent + project, - // so that on failure the assertion message contains enough info to diagnose CI-only failures. - var dump = new System.Text.StringBuilder(); - dump.AppendLine("---- TEMP DEBUG DUMP ----"); - foreach (var (label, res) in new[] - { - ("python-app (agent.Resource)", (IResource)agent.Resource), - ("hosted-agent", hostedAgent), - ("project (my-project)", project.Resource) - }) - { - dump.AppendLine($"== {label} env vars =="); - if (!res.TryGetEnvironmentVariables(out var callbacks)) - { - dump.AppendLine(" (no env var callbacks)"); - continue; - } - var ctx = new EnvironmentCallbackContext(builder.ExecutionContext, res, new Dictionary()); - foreach (var cb in callbacks) - { - try - { - await cb.Callback(ctx); - } - catch (Exception ex) - { - dump.AppendLine($" (callback threw: {ex.GetType().Name}: {ex.Message})"); - } - } - dump.AppendLine($" count = {ctx.EnvironmentVariables.Count}"); - foreach (var (k, v) in ctx.EnvironmentVariables) - { - dump.AppendLine($" {k} = [{v?.GetType().FullName}] {v}"); - } - } - // Dump pipeline steps and annotations on the python app to spot a leak via annotation sharing. - dump.AppendLine("== python-app annotations =="); - foreach (var ann in agent.Resource.Annotations) - { - dump.AppendLine($" {ann.GetType().FullName}"); - } - dump.AppendLine($"== project.Outputs keys =="); - foreach (var key in project.Resource.Outputs.Keys) - { - dump.AppendLine($" {key} = {project.Resource.Outputs[key]}"); - } - dump.AppendLine("---- END DUMP ----"); + // 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)); - try - { - var envVars = await AzureHostedAgentResource.GetResolvedEnvironmentVariablesAsync( - builder.ExecutionContext, - hostedAgent, - agent.Resource, - NullLogger.Instance, - cts.Token); - - Assert.Equal("https://project.example.com/toolboxes/field-tools/versions/v1/mcp?api-version=v1", envVars["ConnectionStrings__field-tools"]); - } - catch (Exception ex) - { - throw new Xunit.Sdk.XunitException($"Resolution failed: {ex.GetType().FullName}: {ex.Message}\n\n{dump}\n\nStack:\n{ex.StackTrace}", ex); - } + var envVars = await AzureHostedAgentResource.GetResolvedEnvironmentVariablesAsync( + builder.ExecutionContext, + hostedAgent, + agent.Resource, + NullLogger.Instance, + cts.Token); + + Assert.Equal("https://project.example.com/toolboxes/field-tools/versions/v1/mcp?api-version=v1", envVars["ConnectionStrings__field-tools"]); } [Fact] From 94d21a6dc5ad0ad7772bffc341bf1c81693c243b Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 30 May 2026 20:01:20 -0700 Subject: [PATCH 04/30] Add MCP auth/headers and auto-derived publish deps for FoundryToolbox Two follow-ups to the Foundry Toolbox MCP integration: 1. FoundryToolboxMcpToolDefinition now exposes AuthorizationTokenExpression and a case-insensitive Headers dictionary. ToProjectsAgentToolAsync resolves both at deploy time and forwards them to ResponseTool.CreateMcpTool so private MCP servers behind a bearer token or custom headers can be wired up declaratively. The new WithMcpTool(..., configure) overloads thread an optional configuration callback so callers don't have to grab the resource to mutate the tool definition. 2. FoundryToolboxResource now installs a PipelineConfigurationAnnotation that walks each MCP tool's EndpointExpression via IValueWithReferences and wires the toolbox deploy-compute step to depend on the deploy-compute step of every referenced compute resource that lives in the app model. This removes the manual WaitFor(...) ordering caveat for the common case where an MCP tool points at a sibling project/container/app service. Run-mode waits are filtered to resources that implement both IComputeResource and IResourceWithWaitSupport so we don't hang on Azure-only or model-only resources that never publish a Running state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../FoundryToolboxBuilderExtensions.cs | 31 +++- .../Toolbox/FoundryToolboxResource.cs | 102 +++++++++++- .../Toolbox/FoundryToolboxToolDefinition.cs | 84 +++++++++- .../ToolboxTests.cs | 151 ++++++++++++++++++ 4 files changed, 352 insertions(+), 16 deletions(-) diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs index 60e496069d8..3052b9b13cd 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs @@ -88,17 +88,24 @@ public static IResourceBuilder WithWebSearchTool( /// The resource builder for the Toolbox. /// The tool name. /// The MCP endpoint URI. + /// + /// Optional callback used to configure the tool definition. Use this to set + /// or populate + /// when the MCP server requires + /// authentication. + /// /// 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) + string endpoint, + Action? configure = null) { ArgumentException.ThrowIfNullOrEmpty(endpoint); - return builder.WithMcpTool(name, ReferenceExpression.Create($"{endpoint}")); + return builder.WithMcpTool(name, ReferenceExpression.Create($"{endpoint}"), configure); } /// @@ -107,17 +114,24 @@ public static IResourceBuilder WithMcpTool( /// The resource builder for the Toolbox. /// The tool name. /// The MCP endpoint. + /// + /// Optional callback used to configure the tool definition. Use this to set + /// or populate + /// when the MCP server requires + /// authentication. + /// /// 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) + EndpointReference endpoint, + Action? configure = null) { ArgumentNullException.ThrowIfNull(endpoint); - return builder.WithMcpTool(name, ReferenceExpression.Create($"{endpoint}")); + return builder.WithMcpTool(name, ReferenceExpression.Create($"{endpoint}"), configure); } /// @@ -131,6 +145,8 @@ internal static IResourceBuilder WithMcpToolForPolyglot( { ArgumentNullException.ThrowIfNull(endpoint); + // Polyglot callers don't yet have a way to express the configure callback so headers/auth + // is currently a .NET-only feature. Tracked as a follow-up. return endpoint switch { string endpointString => builder.WithMcpTool(name, endpointString), @@ -173,13 +189,16 @@ public static IResourceBuilder WithAISearchTool( private static IResourceBuilder WithMcpTool( this IResourceBuilder builder, string name, - ReferenceExpression endpointExpression) + ReferenceExpression endpointExpression, + Action? configure = null) { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(name); ArgumentNullException.ThrowIfNull(endpointExpression); - builder.Resource.AddTool(new FoundryToolboxMcpToolDefinition(name, endpointExpression)); + var tool = new FoundryToolboxMcpToolDefinition(name, endpointExpression); + configure?.Invoke(tool); + builder.Resource.AddTool(tool); return builder; } diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs index 8e99d365ddb..1198cc0c8db 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs @@ -95,6 +95,24 @@ public FoundryToolboxResource([ResourceName] string name, AzureCognitiveServices return Task.FromResult>(steps); })); + + // In publish mode the Foundry data plane calls back into MCP servers, so any MCP tool that + // points at a sibling compute resource (project, container, executable, app service, ACA) + // must have that resource deployed before the toolbox registers the URL. Walk the resolved + // pipeline graph and wire a tag-based dependency from this resource's deploy-compute step + // to every referenced compute resource's deploy-compute step. + Annotations.Add(new PipelineConfigurationAnnotation(context => + { + var toolboxDeploySteps = context.GetSteps(this, WellKnownPipelineTags.DeployCompute); + + foreach (var referenced in GetMcpReferencedResources(context.Model)) + { + var referencedDeploySteps = context.GetSteps(referenced, WellKnownPipelineTags.DeployCompute); + toolboxDeploySteps.DependsOn(referencedDeploySteps); + } + + return Task.CompletedTask; + })); } /// @@ -296,6 +314,7 @@ private async Task DeployForRunModeAsync( CancellationToken cancellationToken) { var notificationService = context.Services.GetRequiredService(); + var model = context.Services.GetRequiredService(); var logger = context.Services.GetRequiredService().GetLogger(this); try { @@ -304,7 +323,7 @@ await notificationService.PublishUpdateAsync(this, s => s with State = new("Waiting for project", KnownResourceStateStyles.Info) }).ConfigureAwait(false); - await WaitForProjectAndToolsAsync(notificationService, cancellationToken).ConfigureAwait(false); + await WaitForProjectAndToolsAsync(notificationService, model, cancellationToken).ConfigureAwait(false); await notificationService.PublishUpdateAsync(this, s => s with { @@ -337,7 +356,7 @@ await notificationService.PublishUpdateAsync(this, s => s with } } - private async Task WaitForProjectAndToolsAsync(ResourceNotificationService notificationService, CancellationToken cancellationToken) + private async Task WaitForProjectAndToolsAsync(ResourceNotificationService notificationService, DistributedApplicationModel model, CancellationToken cancellationToken) { if (Parent is IAzureResource { ProvisioningTaskCompletionSource: { } projectProvisioning }) { @@ -353,7 +372,7 @@ await notificationService.WaitForResourceAsync( // The only sub-resources we need to await are the Azure AI Search project connections // backing AzureAISearch toolbox tools. WebSearch needs no provisioning, and MCP tools - // expose only a URL expression (see the note in FoundryToolboxMcpToolDefinition). + // expose only a URL expression - those are handled separately below. var toolConnectionProvisioningTasks = _tools .Select(tool => tool switch { @@ -362,6 +381,81 @@ await notificationService.WaitForResourceAsync( }) .OfType(); - await Task.WhenAll(toolConnectionProvisioningTasks.Select(task => task.WaitAsync(cancellationToken))).ConfigureAwait(false); + // Wait for any locally-hosted compute resources targeted by MCP tools before we try to + // register their URLs with Foundry. Restrict to IComputeResource + IResourceWithWaitSupport + // so we don't block forever on Azure-only or model-only resources that never publish a + // Running state via the notification service. + var mcpRunModeWaits = GetMcpReferencedResources(model) + .Where(r => r is IComputeResource and IResourceWithWaitSupport) + .Select(r => notificationService.WaitForResourceAsync(r.Name, KnownResourceStates.Running, cancellationToken)); + + await Task.WhenAll(toolConnectionProvisioningTasks + .Select(task => task.WaitAsync(cancellationToken)) + .Concat(mcpRunModeWaits)).ConfigureAwait(false); + } + + // Returns the distinct set of resources referenced by the MCP tools' endpoint expressions that + // also exist in the current application model. Restricting to model membership avoids waiting on + // externally constructed references (e.g. a synthetic EndpointReference produced for test setup) + // and avoids self-dependencies that would deadlock the pipeline. + private IEnumerable GetMcpReferencedResources(DistributedApplicationModel model) + { + var modelResources = new HashSet(model.Resources); + var seen = new HashSet(); + + foreach (var tool in _tools.OfType()) + { + foreach (var referenced in WalkValueReferences(tool.EndpointExpression).OfType()) + { + if (ReferenceEquals(referenced, this)) + { + continue; + } + + if (!modelResources.Contains(referenced)) + { + continue; + } + + if (seen.Add(referenced)) + { + yield return referenced; + } + } + } + } + + // Depth-first walk of every object reachable via IValueWithReferences. Used to surface the + // concrete IResource instances that back an arbitrary ReferenceExpression so we can wire + // pipeline dependencies and run-mode waits regardless of how the user composed the expression + // (raw EndpointReference, EndpointReferenceExpression, nested ReferenceExpression, etc.). + 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 withRefs) + { + foreach (var reference in withRefs.References) + { + if (reference is not null) + { + stack.Push(reference); + } + } + } + } } } diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs index ba71c687aa4..54586e4eb08 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs @@ -70,6 +70,32 @@ internal FoundryToolboxMcpToolDefinition(string name, ReferenceExpression endpoi /// public ReferenceExpression EndpointExpression { get; } + /// + /// Gets or sets an optional expression that resolves to the bearer token sent to the MCP + /// server on every request. Use this for MCP servers that require authorization. + /// + /// + /// When set, the resolved value is forwarded to the Foundry data plane and the Foundry agent + /// runtime sends it as the Authorization header (with the standard Bearer scheme) + /// when invoking the MCP server. The expression is resolved at toolbox deploy time so it can + /// safely reference instances or other + /// deploy-time values. + /// + public ReferenceExpression? AuthorizationTokenExpression { get; set; } + + /// + /// Gets the set of HTTP headers the Foundry agent runtime sends to the MCP server on every + /// request. Header names are matched case-insensitively, mirroring HTTP semantics. + /// + /// + /// Each value is a that is resolved at toolbox deploy time + /// so headers can safely reference instances + /// or other deploy-time values. Header entries whose value resolves to + /// or an empty string are silently omitted. + /// + public IDictionary Headers { get; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + internal override async ValueTask ToProjectsAgentToolAsync(CancellationToken cancellationToken) { var endpoint = await EndpointExpression.GetValueAsync(cancellationToken).ConfigureAwait(false); @@ -79,12 +105,58 @@ internal override async ValueTask ToProjectsAgentToolAsync(Ca $"MCP tool '{Name}' does not have a resolvable endpoint URI."); } - // NOTE: When the MCP endpoint points at a compute resource owned by this app (for example an - // EndpointReference from a project, container, or executable), the published URL is only - // reachable after that resource has been deployed. Toolbox creation does not currently take - // a pipeline dependency on referenced compute, so an MCP tool that points at a sibling - // resource may need a manual `WaitFor(...)` to ensure correct ordering during `aspire deploy`. - return ResponseTool.CreateMcpTool(serverLabel: Name, serverUri: new Uri(endpoint)).AsAgentTool(); + string? authorizationToken = null; + if (AuthorizationTokenExpression is not null) + { + authorizationToken = await AuthorizationTokenExpression.GetValueAsync(cancellationToken).ConfigureAwait(false); + if (string.IsNullOrEmpty(authorizationToken)) + { + throw new InvalidOperationException( + $"MCP tool '{Name}' was configured with an authorization token but the token expression resolved to an empty value."); + } + } + + // Resolve every configured header to its concrete value, dropping any that produce a + // null/empty string (Headers is intended for explicit values; empty values are a no-op + // rather than a configuration error). + IDictionary? headers = null; + if (Headers.Count > 0) + { + headers = new Dictionary(Headers.Count, StringComparer.OrdinalIgnoreCase); + foreach (var header in Headers) + { + if (string.IsNullOrEmpty(header.Key)) + { + throw new InvalidOperationException( + $"MCP tool '{Name}' has a header with an empty name."); + } + + var value = await header.Value.GetValueAsync(cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrEmpty(value)) + { + headers[header.Key] = value; + } + } + + // Drop the dictionary if every entry resolved to an empty value: the SDK distinguishes + // null from an empty collection in some wire forms, and we prefer "no headers configured" + // semantics when nothing was actually supplied. + if (headers.Count == 0) + { + headers = null; + } + } + + // Always go through the full ResponseTool.CreateMcpTool overload so the auth/header path is + // exercised consistently. Both parameters accept null with the same effect as omitting them. + return ResponseTool.CreateMcpTool( + serverLabel: Name, + serverUri: new Uri(endpoint), + authorizationToken: authorizationToken, + serverDescription: null, + headers: headers, + allowedTools: null, + toolCallApprovalPolicy: null).AsAgentTool(); } } diff --git a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs index fa3f3994083..90b32f31af2 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs @@ -265,6 +265,157 @@ await Assert.ThrowsAsync( async () => await def.ToProjectsAgentToolAsync(CancellationToken.None)); } + [Fact] + public void WithMcpTool_ConfigureCallback_SetsAuthorizationTokenAndHeaders() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var project = builder.AddFoundry("account") + .AddProject("my-project"); + var token = builder.AddParameter("mcp-token", () => "secret-value"); + var traceparent = builder.AddParameter("traceparent", () => "00-aaaa-bbbb-01"); + + var toolbox = project.AddToolbox("field-tools") + .WithMcpTool("inventory", "https://inventory.example.com/mcp", t => + { + t.AuthorizationTokenExpression = ReferenceExpression.Create($"{token.Resource}"); + t.Headers["X-Trace-Parent"] = ReferenceExpression.Create($"{traceparent.Resource}"); + // Header name matching is case-insensitive: writing through one casing and then + // reading through another must resolve to the same entry. + t.Headers["x-trace-parent"] = ReferenceExpression.Create($"{traceparent.Resource}-2"); + }); + + var def = Assert.IsType(toolbox.Resource.Tools[0]); + + Assert.NotNull(def.AuthorizationTokenExpression); + Assert.Single(def.Headers); + Assert.True(def.Headers.ContainsKey("X-TRACE-PARENT")); + } + + [Fact] + public async Task McpToolDefinition_AuthorizationToken_EmptyValue_Throws() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var empty = builder.AddParameter("empty-token", () => string.Empty); + + var def = new FoundryToolboxMcpToolDefinition( + "inventory", + ReferenceExpression.Create($"https://inventory.example.com/mcp")) + { + AuthorizationTokenExpression = ReferenceExpression.Create($"{empty.Resource}") + }; + + var ex = await Assert.ThrowsAsync( + async () => await def.ToProjectsAgentToolAsync(CancellationToken.None)); + Assert.Contains("authorization token", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task McpToolDefinition_Headers_EmptyValueOmitted_DoesNotThrow() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var present = builder.AddParameter("present", () => "v1"); + var empty = builder.AddParameter("empty-header", () => string.Empty); + + var def = new FoundryToolboxMcpToolDefinition( + "inventory", + ReferenceExpression.Create($"https://inventory.example.com/mcp")); + def.Headers["X-Present"] = ReferenceExpression.Create($"{present.Resource}"); + // Empty header values are silently dropped (vs. throwing) so optional headers backed by + // parameters can be safely omitted in environments where the upstream value isn't set. + def.Headers["X-Optional"] = ReferenceExpression.Create($"{empty.Resource}"); + + var projectTool = await def.ToProjectsAgentToolAsync(CancellationToken.None); + + Assert.NotNull(projectTool); + } + + [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(); + + // 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 = mcp.Resource, + 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); + } + private static PipelineContext CreatePipelineContext(DistributedApplication app, DistributedApplicationOperation operation) { var model = app.Services.GetRequiredService(); From 65205f17c2801f3baf0d29bea088f8627ff40981 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 30 May 2026 21:00:28 -0700 Subject: [PATCH 05/30] Add TS Foundry Toolbox + auth MCP playground sample Adds an end-to-end playground showcasing the new polyglot auth/headers support on FoundryToolboxMcpToolOptions: * WithMcpToolForPolyglot now accepts an optional FoundryToolboxMcpToolOptions (AuthorizationToken, Headers), projected onto the existing .NET configure callback so polyglot AppHosts can wire bearer-token / custom-header MCP servers without dropping to C#. * playground/FoundryAgentToolboxTs/ - a TypeScript AppHost (apphost.mts) that wires a Foundry agent + Toolbox to a custom Node MCP server which validates Authorization: Bearer . A secret mcp-bearer-token parameter is fed into both the MCP server (env var) and the toolbox tool definition (authorizationToken), with an extra x-app-source custom header to exercise the headers feature. * The MCP server uses @modelcontextprotocol/sdk with the streamable HTTP transport, exposes lookup_employee and record_note tools, and was smoke-tested end-to-end (init handshake, tools/list, tools/call) with bearer auth enforced. NuGet.config and 13.5.0-dev pins are temporary until this PR ships in a release of Aspire.Hosting.Foundry; the README documents the dev-loop workflow and the cleanup steps. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- playground/FoundryAgentToolboxTs/NuGet.config | 22 + playground/FoundryAgentToolboxTs/README.md | 79 + playground/FoundryAgentToolboxTs/apphost.mts | 63 + .../FoundryAgentToolboxTs/aspire.config.json | 10 + .../mcp-server/package-lock.json | 2210 +++++++++++++++++ .../mcp-server/package.json | 23 + .../mcp-server/src/server.ts | 204 ++ .../mcp-server/tsconfig.json | 20 + .../FoundryAgentToolboxTs/package-lock.json | 578 +++++ playground/FoundryAgentToolboxTs/package.json | 20 + .../tsconfig.apphost.json | 20 + .../FoundryAgentToolboxTs/tsconfig.json | 21 + .../FoundryToolboxBuilderExtensions.cs | 32 +- .../Toolbox/FoundryToolboxMcpToolOptions.cs | 35 + 14 files changed, 3332 insertions(+), 5 deletions(-) create mode 100644 playground/FoundryAgentToolboxTs/NuGet.config create mode 100644 playground/FoundryAgentToolboxTs/README.md create mode 100644 playground/FoundryAgentToolboxTs/apphost.mts create mode 100644 playground/FoundryAgentToolboxTs/aspire.config.json create mode 100644 playground/FoundryAgentToolboxTs/mcp-server/package-lock.json create mode 100644 playground/FoundryAgentToolboxTs/mcp-server/package.json create mode 100644 playground/FoundryAgentToolboxTs/mcp-server/src/server.ts create mode 100644 playground/FoundryAgentToolboxTs/mcp-server/tsconfig.json create mode 100644 playground/FoundryAgentToolboxTs/package-lock.json create mode 100644 playground/FoundryAgentToolboxTs/package.json create mode 100644 playground/FoundryAgentToolboxTs/tsconfig.apphost.json create mode 100644 playground/FoundryAgentToolboxTs/tsconfig.json create mode 100644 src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxMcpToolOptions.cs diff --git a/playground/FoundryAgentToolboxTs/NuGet.config b/playground/FoundryAgentToolboxTs/NuGet.config new file mode 100644 index 00000000000..6e97cad0244 --- /dev/null +++ b/playground/FoundryAgentToolboxTs/NuGet.config @@ -0,0 +1,22 @@ + + + + + + + + + + + + + diff --git a/playground/FoundryAgentToolboxTs/README.md b/playground/FoundryAgentToolboxTs/README.md new file mode 100644 index 00000000000..25e8262e065 --- /dev/null +++ b/playground/FoundryAgentToolboxTs/README.md @@ -0,0 +1,79 @@ +# FoundryAgentToolboxTs + +End-to-end sample of an Aspire **TypeScript AppHost** wiring a Foundry Agent + **Foundry Toolbox** to a **custom MCP server** that requires bearer-token authentication. + +## What it shows + +- TypeScript AppHost (`apphost.mts`) using `Aspire.Hosting.Foundry` and `Aspire.Hosting.JavaScript`. +- A custom MCP server (Node + Express + `@modelcontextprotocol/sdk`) implementing the streamable HTTP transport and validating an `Authorization: Bearer ` header. +- A `mcp-bearer-token` parameter wired into **both** sides of the call: + - Into the MCP server as the `MCP_BEARER_TOKEN` env var (for validation). + - Into the Toolbox `withMcpTool(...)` definition as the outbound `authorizationToken` (forwarded by the Foundry data plane as the `Authorization` header). +- An extra `x-app-source` custom header on the MCP tool to demonstrate the headers feature. + +## Topology + +``` +mcp-bearer-token (parameter, secret) + | + +--> mcp-server (Node) + | validates Authorization: Bearer + | + +--> foundry.project.field-tools toolbox + .withWebSearchTool() + .withMcpTool('directory', mcpServer http endpoint, + { authorizationToken: refExpr`${bearerToken}`, + headers: { 'x-app-source': refExpr`foundry-toolbox-ts-sample` } }) +``` + +## Run locally + +> **Pre-release wiring.** This playground depends on the polyglot `authorizationToken` / `headers` +> options added in `Aspire.Hosting.Foundry` by PR #17742, which is not yet in a shipped package. +> Until that PR ships, the local `NuGet.config` here points at `../../artifacts/packages/Debug/Shipping` +> and `aspire.config.json` pins both Foundry / JavaScript hosting packages to `13.5.0-dev`. Before +> running the sample for the first time, pack the in-repo packages: +> +> ```bash +> # from the repo root +> dotnet pack src/Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.csproj -c Debug --no-restore +> dotnet pack src/Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.csproj -c Debug --no-restore +> ``` +> +> Once #17742 ships in a release, delete `NuGet.config` here and set both packages back to `""` in +> `aspire.config.json` (matching `playground/TypeScriptAppHost/aspire.config.json`). + +```bash +cd playground/FoundryAgentToolboxTs/mcp-server && npm install +cd .. && aspire start +``` + +The first time you run it, the CLI prompts for the `mcp-bearer-token` value. Use any non-empty string; the same value flows to the MCP server and to the toolbox tool definition. + +You can manually verify the auth behavior: + +```bash +TOKEN= +ENDPOINT=$(curl -s http://localhost:5252/health) # just to confirm the server is up +curl -i http://localhost:5252/mcp # 401 (no bearer) +curl -i -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}},"id":1}' \ + http://localhost:5252/mcp # initializes a session +``` + +The actual MCP endpoint port is assigned by Aspire, look it up in the dashboard for the `mcp-server` resource. + +## Important caveat + +The **Foundry data plane** is what invokes the MCP server when an agent calls the `directory` tool. The data plane runs in Azure, so it cannot reach `http://localhost:*` MCP endpoints. In `aspire start` mode you can: + +- Verify all resources are healthy. +- Verify the MCP server rejects unauthenticated requests and accepts authenticated ones. +- Inspect the generated Foundry toolbox manifest (via `aspire publish`). + +For a real end-to-end tool invocation from a Foundry agent, run `aspire publish` (or `aspire deploy`) so the MCP server runs on Azure Container Apps with public ingress. + +## Tools exposed by the MCP server + +- `lookup_employee(name)`: returns a fake directory record for one of a handful of names. +- `record_note(subject, body)`: records an in-memory note and returns the assigned id. diff --git a/playground/FoundryAgentToolboxTs/apphost.mts b/playground/FoundryAgentToolboxTs/apphost.mts new file mode 100644 index 00000000000..d5ff437f2f7 --- /dev/null +++ b/playground/FoundryAgentToolboxTs/apphost.mts @@ -0,0 +1,63 @@ +// FoundryAgentToolboxTs sample +// +// Demonstrates wiring a Foundry Agent that calls a custom MCP server via the Foundry Toolbox, +// from a TypeScript AppHost, where the MCP server requires bearer-token authentication. +// +// Topology: +// +// mcp-bearer-token (parameter, secret) +// | +// +--> mcp-server (Node, Express, @modelcontextprotocol/sdk) +// | validates Authorization: Bearer +// | +// +--> foundry.project.field-tools toolbox +// .withMcpTool('directory', mcpServer http endpoint, +// { authorizationToken: refExpr`${bearerToken}`, headers: { ... } }) +// +// At deploy time the toolbox is created on the Foundry data plane and the MCP tool is registered +// with the bearer token as its outbound Authorization header. When a Foundry agent invokes a tool, +// the Foundry data plane calls the MCP server with that header, the server validates it, and the +// tool runs. +// +// In `aspire start` (local) mode the Foundry data plane lives in Azure and cannot reach +// http://localhost:* MCP servers, so end-to-end tool invocations only fire after `aspire deploy`. +// The local run still validates that all resources start healthy, the MCP server enforces auth, +// and `aspire publish` produces correct artifacts (bicep + compose). + +import { createBuilder, refExpr, FoundryModels } from './.aspire/modules/aspire.mjs'; + +const builder = await createBuilder(); + +const bearerToken = await builder.addParameter('mcp-bearer-token', { secret: true }); + +const mcpServer = await builder + .addNodeApp('mcp-server', './mcp-server', 'src/server.ts') + .withRunScript('start') + .withHttpEndpoint({ env: 'PORT' }) + .withEnvironment('MCP_BEARER_TOKEN', bearerToken); + +const foundry = await builder.addFoundry('foundry'); +const project = await foundry.addProject('project'); + +// Chat model the Foundry-side agent will use. The toolbox tools are visible to any agent in the +// project that opts into the toolbox. +const _chat = await project.addModelDeployment('chat', FoundryModels.OpenAI.Gpt41Mini); + +const toolbox = await project.addToolbox('field-tools', { version: 'v1' }); + +// A built-in Foundry tool, mainly to show the toolbox can mix Foundry-managed and custom tools. +await toolbox.withWebSearchTool(); + +// The custom MCP tool. The Foundry data plane calls mcpServer over HTTP and sends the bearer +// token in the Authorization header, so the same parameter we gave the MCP server above also +// flows out from Foundry as auth. +await toolbox.withMcpTool('directory', mcpServer.getEndpoint('http'), { + authorizationToken: refExpr`${bearerToken}`, + headers: { + // Custom tracing/tenant header so operators can correlate Foundry-initiated calls in the + // MCP server logs. + 'x-app-source': refExpr`foundry-toolbox-ts-sample` + } +}); + +await builder.build().run(); diff --git a/playground/FoundryAgentToolboxTs/aspire.config.json b/playground/FoundryAgentToolboxTs/aspire.config.json new file mode 100644 index 00000000000..153111e5937 --- /dev/null +++ b/playground/FoundryAgentToolboxTs/aspire.config.json @@ -0,0 +1,10 @@ +{ + "appHost": { + "path": "apphost.mts", + "language": "typescript/nodejs" + }, + "packages": { + "Aspire.Hosting.Foundry": "13.5.0-dev", + "Aspire.Hosting.JavaScript": "13.5.0-dev" + } +} diff --git a/playground/FoundryAgentToolboxTs/mcp-server/package-lock.json b/playground/FoundryAgentToolboxTs/mcp-server/package-lock.json new file mode 100644 index 00000000000..4c5d323ef1e --- /dev/null +++ b/playground/FoundryAgentToolboxTs/mcp-server/package-lock.json @@ -0,0 +1,2210 @@ +{ + "name": "directory-mcp-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "directory-mcp-server", + "version": "1.0.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.18.0", + "express": "^4.21.2", + "zod": "^3.24.1" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.0.0", + "tsx": "^4.19.0", + "typescript": "^5.3.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/router/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/playground/FoundryAgentToolboxTs/mcp-server/package.json b/playground/FoundryAgentToolboxTs/mcp-server/package.json new file mode 100644 index 00000000000..9229ec3dbfa --- /dev/null +++ b/playground/FoundryAgentToolboxTs/mcp-server/package.json @@ -0,0 +1,23 @@ +{ + "name": "directory-mcp-server", + "version": "1.0.0", + "description": "Sample MCP server protected by bearer-token auth, called by the Foundry Toolbox over HTTP.", + "type": "module", + "private": true, + "scripts": { + "start": "tsx src/server.ts", + "dev": "tsx src/server.ts", + "build": "tsc" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.18.0", + "express": "^4.21.2", + "zod": "^3.24.1" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.0.0", + "tsx": "^4.19.0", + "typescript": "^5.3.0" + } +} diff --git a/playground/FoundryAgentToolboxTs/mcp-server/src/server.ts b/playground/FoundryAgentToolboxTs/mcp-server/src/server.ts new file mode 100644 index 00000000000..a2b619de469 --- /dev/null +++ b/playground/FoundryAgentToolboxTs/mcp-server/src/server.ts @@ -0,0 +1,204 @@ +// Minimal MCP server protected by a bearer token. The Foundry Toolbox sends the token in the +// Authorization header on every request; the AppHost wires the same token into both this server +// (for validation) and the toolbox MCP tool definition (for outbound auth) via a single shared +// parameter. +// +// Transport: streamable HTTP, mounted at /mcp. +// - POST /mcp - JSON-RPC requests + tool invocations +// - GET /mcp - SSE stream for server-initiated messages +// - DELETE /mcp - explicit session termination +// +// Tools exposed: +// - lookup_employee(name): returns a fake directory record +// - record_note(subject, body): records a note in-memory and returns the assigned id +// +// References: +// - https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http +// - https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/toolbox?pivots=javascript + +import { randomUUID } from 'node:crypto'; +import express, { type Request, type Response } from 'express'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; + +const PORT = Number(process.env.PORT ?? process.env.MCP_PORT ?? 5252); +const TOKEN = process.env.MCP_BEARER_TOKEN; + +if (!TOKEN) { + console.error('MCP_BEARER_TOKEN environment variable is required.'); + process.exit(1); +} + +interface DirectoryEntry { + readonly name: string; + readonly title: string; + readonly office: string; + readonly email: string; +} + +const directory: Record = { + 'ada lovelace': { name: 'Ada Lovelace', title: 'Principal Engineer', office: 'London', email: 'ada@contoso.example' }, + 'grace hopper': { name: 'Grace Hopper', title: 'Distinguished Engineer', office: 'New York', email: 'grace@contoso.example' }, + 'alan turing': { name: 'Alan Turing', title: 'Researcher', office: 'Cambridge', email: 'alan@contoso.example' } +}; + +interface Note { + readonly id: string; + readonly subject: string; + readonly body: string; + readonly createdAt: string; +} + +const notes: Note[] = []; + +function buildServer(): McpServer { + const server = new McpServer( + { name: 'directory-mcp', version: '1.0.0' }, + { capabilities: { tools: {} } }); + + server.registerTool( + 'lookup_employee', + { + description: 'Look up an employee in the company directory by name. Returns title, office, and email.', + inputSchema: { name: z.string().describe('The employee full name to look up.') } + }, + async ({ name }) => { + const entry = directory[name.trim().toLowerCase()]; + if (!entry) { + return { content: [{ type: 'text', text: `No employee named '${name}' found in the directory.` }] }; + } + return { + content: [{ + type: 'text', + text: `Name: ${entry.name}\nTitle: ${entry.title}\nOffice: ${entry.office}\nEmail: ${entry.email}` + }] + }; + }); + + server.registerTool( + 'record_note', + { + description: 'Record a short note for the team. Returns the assigned note id.', + inputSchema: { + subject: z.string().describe('Short subject line.'), + body: z.string().describe('Note body.') + } + }, + async ({ subject, body }) => { + const note: Note = { id: randomUUID(), subject, body, createdAt: new Date().toISOString() }; + notes.push(note); + return { content: [{ type: 'text', text: `Recorded note ${note.id} ('${subject}').` }] }; + }); + + return server; +} + +const app = express(); +app.use(express.json()); + +// Unauthenticated liveness probe so the AppHost can mark the resource healthy without leaking +// the bearer token requirement onto the health-check path. +app.get('/health', (_req, res) => { + res.status(200).json({ status: 'ok' }); +}); + +// Bearer token middleware. The token comes from MCP_BEARER_TOKEN; the AppHost forwards the same +// value to the Toolbox so its outbound calls authenticate cleanly. +app.use((req, res, next) => { + const auth = req.header('authorization') ?? ''; + const expected = `Bearer ${TOKEN}`; + if (auth !== expected) { + res.status(401).json({ error: 'unauthorized', message: 'Missing or invalid bearer token.' }); + return; + } + next(); +}); + +// One transport per MCP session. The session id arrives in the Mcp-Session-Id header on every +// request after initialization (per the Streamable HTTP spec). +const transports = new Map(); + +async function handleMcpPost(req: Request, res: Response): Promise { + const sessionHeader = req.header('mcp-session-id'); + let transport = sessionHeader ? transports.get(sessionHeader) : undefined; + + if (!transport) { + // Either the very first request (initialize) or a request with a stale/missing session id. + if (!isInitializeRequest(req.body)) { + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Bad Request: No valid MCP session.' }, + id: null + }); + return; + } + + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (id) => { + transports.set(id, transport!); + } + }); + + // Clean up server-side state when the client disconnects so transports doesn't grow unbounded. + transport.onclose = () => { + if (transport!.sessionId) { + transports.delete(transport!.sessionId); + } + }; + + const server = buildServer(); + await server.connect(transport); + } + + await transport.handleRequest(req, res, req.body); +} + +async function handleMcpSessionRequest(req: Request, res: Response): Promise { + const sessionHeader = req.header('mcp-session-id'); + const transport = sessionHeader ? transports.get(sessionHeader) : undefined; + if (!transport) { + res.status(400).send('Invalid or missing Mcp-Session-Id header.'); + return; + } + await transport.handleRequest(req, res); +} + +app.post('/mcp', (req, res) => { + handleMcpPost(req, res).catch((err) => { + console.error('Error handling MCP POST', err); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { code: -32603, message: 'Internal server error' }, + id: null + }); + } + }); +}); + +app.get('/mcp', (req, res) => { + handleMcpSessionRequest(req, res).catch((err) => { + console.error('Error handling MCP GET', err); + if (!res.headersSent) { + res.status(500).send('Internal server error'); + } + }); +}); + +app.delete('/mcp', (req, res) => { + handleMcpSessionRequest(req, res).catch((err) => { + console.error('Error handling MCP DELETE', err); + if (!res.headersSent) { + res.status(500).send('Internal server error'); + } + }); +}); + +app.listen(PORT, () => { + console.log(`Directory MCP server listening on http://0.0.0.0:${PORT}/mcp`); + console.log('Health endpoint: /health'); + console.log('Authorization: Bearer required for /mcp'); +}); diff --git a/playground/FoundryAgentToolboxTs/mcp-server/tsconfig.json b/playground/FoundryAgentToolboxTs/mcp-server/tsconfig.json new file mode 100644 index 00000000000..39f1a1f2789 --- /dev/null +++ b/playground/FoundryAgentToolboxTs/mcp-server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/playground/FoundryAgentToolboxTs/package-lock.json b/playground/FoundryAgentToolboxTs/package-lock.json new file mode 100644 index 00000000000..4d865afa602 --- /dev/null +++ b/playground/FoundryAgentToolboxTs/package-lock.json @@ -0,0 +1,578 @@ +{ + "name": "foundry-agent-toolbox-ts", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "foundry-agent-toolbox-ts", + "version": "1.0.0", + "dependencies": { + "vscode-jsonrpc": "^8.2.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsx": "^4.19.0", + "typescript": "^5.3.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + } + } +} diff --git a/playground/FoundryAgentToolboxTs/package.json b/playground/FoundryAgentToolboxTs/package.json new file mode 100644 index 00000000000..e2c3f59ca81 --- /dev/null +++ b/playground/FoundryAgentToolboxTs/package.json @@ -0,0 +1,20 @@ +{ + "name": "foundry-agent-toolbox-ts", + "version": "1.0.0", + "description": "TypeScript AppHost wiring up a Foundry Agent + Toolbox + custom auth-protected MCP server.", + "type": "module", + "private": true, + "scripts": { + "start": "aspire run", + "build": "tsc --noEmit", + "dev": "tsc --noEmit --watch" + }, + "dependencies": { + "vscode-jsonrpc": "^8.2.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsx": "^4.19.0", + "typescript": "^5.3.0" + } +} diff --git a/playground/FoundryAgentToolboxTs/tsconfig.apphost.json b/playground/FoundryAgentToolboxTs/tsconfig.apphost.json new file mode 100644 index 00000000000..e81bdc9d399 --- /dev/null +++ b/playground/FoundryAgentToolboxTs/tsconfig.apphost.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "./dist/apphost", + "rootDir": "." + }, + "include": [ + "apphost.mts", + ".aspire/modules/aspire.mts", + ".aspire/modules/base.mts", + ".aspire/modules/transport.mts" + ], + "exclude": ["node_modules"] +} diff --git a/playground/FoundryAgentToolboxTs/tsconfig.json b/playground/FoundryAgentToolboxTs/tsconfig.json new file mode 100644 index 00000000000..e6e437b3d63 --- /dev/null +++ b/playground/FoundryAgentToolboxTs/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": "." + }, + "include": [ + "apphost.mts", + ".aspire/modules/**/*.mts" + ], + "exclude": [ + "node_modules", + "mcp-server" + ] +} diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs index 3052b9b13cd..2d8562160e5 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs @@ -137,20 +137,42 @@ public static IResourceBuilder WithMcpTool( /// /// Adds an MCP tool definition to the Toolbox. /// + /// The resource builder for the Toolbox. + /// The tool name. + /// The MCP endpoint (string URI or endpoint reference). + /// Optional auth/headers configuration. + /// 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))] object endpoint) + [AspireUnion(typeof(string), typeof(EndpointReference))] object endpoint, + FoundryToolboxMcpToolOptions? options = null) { ArgumentNullException.ThrowIfNull(endpoint); - // Polyglot callers don't yet have a way to express the configure callback so headers/auth - // is currently a .NET-only feature. Tracked as a follow-up. + // Project the polyglot options onto the same configure callback the .NET overloads use so + // headers/auth wiring stays consistent across surface areas. + Action? configure = options is null + ? null + : tool => + { + if (options.AuthorizationToken is not null) + { + tool.AuthorizationTokenExpression = options.AuthorizationToken; + } + + foreach (var header in options.Headers) + { + tool.Headers[header.Key] = header.Value; + } + }; + return endpoint switch { - string endpointString => builder.WithMcpTool(name, endpointString), - EndpointReference endpointReference => builder.WithMcpTool(name, endpointReference), + string endpointString => builder.WithMcpTool(name, endpointString, configure), + EndpointReference endpointReference => builder.WithMcpTool(name, endpointReference, configure), _ => throw new ArgumentException("Endpoint must be a string or endpoint reference.", nameof(endpoint)) }; } diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxMcpToolOptions.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxMcpToolOptions.cs new file mode 100644 index 00000000000..738114ade36 --- /dev/null +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxMcpToolOptions.cs @@ -0,0 +1,35 @@ +// 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; + +/// +/// Options used when adding an MCP tool definition to a Microsoft Foundry Toolbox from a polyglot +/// AppHost. Mirrors the auth/header configuration available via the .NET +/// callback overloads of WithMcpTool. +/// +[AspireDto] +internal sealed class FoundryToolboxMcpToolOptions +{ + /// + /// Gets or sets an optional expression that resolves to the bearer token sent to the MCP + /// server on every request. Use this for MCP servers that require authorization. + /// + /// + /// When set, the resolved value is forwarded to the Foundry data plane and the Foundry agent + /// runtime sends it as the Authorization header (with the standard Bearer scheme) + /// when invoking the MCP server. + /// + public ReferenceExpression? AuthorizationToken { get; set; } + + /// + /// Gets the set of HTTP headers the Foundry agent runtime sends to the MCP server on every + /// request. Header names are matched case-insensitively, mirroring HTTP semantics. Header + /// entries whose value resolves to or an empty string are silently + /// omitted. + /// + public IDictionary Headers { get; init; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); +} From 35cc422115758166bc8b0d5433a4dabd1f4821c6 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 31 May 2026 17:29:27 -0700 Subject: [PATCH 06/30] Bypass ModelReaderWriter.Write for OpenAI Responses tools in polyglot AppHost The natural FoundryToolbox{WebSearch,Mcp}ToolDefinition.ToProjectsAgentToolAsync implementation goes through ResponseTool.Create*().AsAgentTool(), which internally calls ModelReaderWriter.Write on an OpenAI.Responses tool. In a normal .NET process this works. In the polyglot (TypeScript) AppHostServer, IntegrationLoadContext loads probe assemblies into a separate ALC, and the prebuilt host's System.ClientModel 1.10.0 ends up bound to OpenAI in the default ALC while the integration's System.ClientModel 1.11.0 lives in the probe ALC. The two SCMs surface as distinct CLR assemblies, so the IPersistableModel interface check inside ModelReaderWriter.Write fails with a misleading 'must implement IEnumerable or IPersistableModel' exception.`Fix: stop calling MRW.Write across the ALC boundary. Both definitions now hand-build the OpenAI Responses wire JSON and feed it to MRW.Read(..., AzureAIProjectsAgentsContext.Default), which stays inside a single ALC (BCL + Azure.AI.Projects.Agents + probe-ALC SCM). Wire shapes match openai-dotnet's McpTool.Serialization.cs and WebSearchTool.Serialization.cs.`Also widens the polyglot WithMcpToolForPolyglot union to accept ReferenceExpression so TS apphosts can use refExpr to compose URLs like \ efExpr\\/mcp\\.`Validated end-to-end against playground/FoundryAgentToolboxTs deployed to Azure: aspire deploy completes deploy-field-tools cleanly and the resulting toolbox version contains both {type:web_search} and the full MCP tool with auth + headers.`Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../FoundryToolboxBuilderExtensions.cs | 13 ++- .../Toolbox/FoundryToolboxToolDefinition.cs | 94 ++++++++++++++++--- 2 files changed, 89 insertions(+), 18 deletions(-) diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs index 2d8562160e5..ee7d399d1b2 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs @@ -139,7 +139,10 @@ public static IResourceBuilder WithMcpTool( /// /// The resource builder for the Toolbox. /// The tool name. - /// The MCP endpoint (string URI or endpoint reference). + /// 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 auth/headers configuration. /// A reference to the for chaining. /// The resource builder. @@ -147,7 +150,7 @@ public static IResourceBuilder WithMcpTool( internal static IResourceBuilder WithMcpToolForPolyglot( this IResourceBuilder builder, string name, - [AspireUnion(typeof(string), typeof(EndpointReference))] object endpoint, + [AspireUnion(typeof(string), typeof(EndpointReference), typeof(ReferenceExpression))] object endpoint, FoundryToolboxMcpToolOptions? options = null) { ArgumentNullException.ThrowIfNull(endpoint); @@ -173,7 +176,11 @@ internal static IResourceBuilder WithMcpToolForPolyglot( { string endpointString => builder.WithMcpTool(name, endpointString, configure), EndpointReference endpointReference => builder.WithMcpTool(name, endpointReference, configure), - _ => throw new ArgumentException("Endpoint must be a string or endpoint reference.", nameof(endpoint)) + // 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, configure), + _ => throw new ArgumentException("Endpoint must be a string, endpoint reference, or reference expression.", nameof(endpoint)) }; } diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs index 54586e4eb08..237b0b14f4a 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs @@ -1,10 +1,11 @@ // 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; -using OpenAI.Responses; namespace Aspire.Hosting.Foundry; @@ -45,10 +46,37 @@ internal FoundryToolboxWebSearchToolDefinition(string name) internal override ValueTask ToProjectsAgentToolAsync(CancellationToken cancellationToken) { - // ResponseTool.CreateWebSearchTool() produces an OpenAI WebSearchTool; AsAgentTool() lifts - // it into the Azure.AI.Projects.Agents wire shape that AgentToolboxes.CreateToolboxVersion - // expects. - return new ValueTask(ResponseTool.CreateWebSearchTool().AsAgentTool()); + // 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. + // + // The OpenAI Responses "web_search" tool has a fixed wire shape: {"type":"web_search"}. + // See https://platform.openai.com/docs/api-reference/responses/create#responses-create-tools. + var json = BinaryData.FromString("""{"type":"web_search"}"""); + var agentTool = ModelReaderWriter.Read(json, ModelReaderWriterOptions.Json, AzureAIProjectsAgentsContext.Default); + return new ValueTask(agentTool!); } } @@ -147,16 +175,52 @@ internal override async ValueTask ToProjectsAgentToolAsync(Ca } } - // Always go through the full ResponseTool.CreateMcpTool overload so the auth/header path is - // exercised consistently. Both parameters accept null with the same effect as omitting them. - return ResponseTool.CreateMcpTool( - serverLabel: Name, - serverUri: new Uri(endpoint), - authorizationToken: authorizationToken, - serverDescription: null, - headers: headers, - allowedTools: null, - toolCallApprovalPolicy: null).AsAgentTool(); + // 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": "", // optional, but required for hosted MCP + // "authorization":"", // optional bearer token (sans "Bearer ") + // "headers": { "": "", ... } // optional extra headers + // } + // 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", Name); + writer.WriteString("server_url", new Uri(endpoint).AbsoluteUri); + + if (!string.IsNullOrEmpty(authorizationToken)) + { + writer.WriteString("authorization", authorizationToken); + } + + if (headers is { Count: > 0 }) + { + writer.WriteStartObject("headers"); + foreach (var header in headers) + { + writer.WriteString(header.Key, header.Value); + } + writer.WriteEndObject(); + } + + writer.WriteEndObject(); + } + + var json = BinaryData.FromBytes(stream.ToArray()); + return ModelReaderWriter.Read(json, ModelReaderWriterOptions.Json, AzureAIProjectsAgentsContext.Default)!; } } From 454e116ea132d3fcebccb1f5cf8186a34b03702e Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 31 May 2026 17:30:29 -0700 Subject: [PATCH 07/30] Flesh out TS Foundry Toolbox sample with dev tunnel + publish-mode topology Iterates on playground/FoundryAgentToolboxTs to exercise the full bearer-auth MCP path against a real Foundry data plane:`- apphost.mts: dual run/publish topology. In run mode the local MCP server is fronted by an Aspire dev tunnel so the Foundry data plane can reach it; in publish mode it deploys as a NodeApp container with public ingress and the toolbox points at its public ACA endpoint. Documents the Foundry data-plane inline-auth gap inline. - mcp-server/src/server.ts: bearer auth middleware + request logging so we can verify whether the Foundry proxy is forwarding the Authorization header. - scripts/invoke-toolbox.mjs: standalone Node consumer that creates a Foundry agent against the deployed toolbox and runs a thread end-to-end. Useful for smoke-testing toolbox deploys without the dashboard. - NuGet.config / aspire.config.json: add Aspire.Hosting.DevTunnels and Aspire.Hosting.Azure.AppContainers package mappings.`Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- playground/FoundryAgentToolboxTs/NuGet.config | 2 + playground/FoundryAgentToolboxTs/apphost.mts | 84 +- .../FoundryAgentToolboxTs/aspire.config.json | 4 +- .../mcp-server/src/server.ts | 18 +- .../scripts/invoke-toolbox.mjs | 82 ++ .../scripts/package-lock.json | 905 ++++++++++++++++++ .../scripts/package.json | 11 + 7 files changed, 1087 insertions(+), 19 deletions(-) create mode 100644 playground/FoundryAgentToolboxTs/scripts/invoke-toolbox.mjs create mode 100644 playground/FoundryAgentToolboxTs/scripts/package-lock.json create mode 100644 playground/FoundryAgentToolboxTs/scripts/package.json diff --git a/playground/FoundryAgentToolboxTs/NuGet.config b/playground/FoundryAgentToolboxTs/NuGet.config index 6e97cad0244..d95cc61860d 100644 --- a/playground/FoundryAgentToolboxTs/NuGet.config +++ b/playground/FoundryAgentToolboxTs/NuGet.config @@ -17,6 +17,8 @@ + + diff --git a/playground/FoundryAgentToolboxTs/apphost.mts b/playground/FoundryAgentToolboxTs/apphost.mts index d5ff437f2f7..b6213fab99e 100644 --- a/playground/FoundryAgentToolboxTs/apphost.mts +++ b/playground/FoundryAgentToolboxTs/apphost.mts @@ -3,39 +3,91 @@ // Demonstrates wiring a Foundry Agent that calls a custom MCP server via the Foundry Toolbox, // from a TypeScript AppHost, where the MCP server requires bearer-token authentication. // -// Topology: +// Topology (run mode): // // mcp-bearer-token (parameter, secret) // | // +--> mcp-server (Node, Express, @modelcontextprotocol/sdk) -// | validates Authorization: Bearer +// | validates Authorization: Bearer on /mcp +// | +// mcp-tunnel (dev tunnel, anonymous public ingress) +// | exposes mcp-server's http endpoint at https://.devtunnels.ms // | // +--> foundry.project.field-tools toolbox -// .withMcpTool('directory', mcpServer http endpoint, +// .withMcpTool('directory', refExpr`${publicEndpoint}/mcp`, // { authorizationToken: refExpr`${bearerToken}`, headers: { ... } }) // -// At deploy time the toolbox is created on the Foundry data plane and the MCP tool is registered -// with the bearer token as its outbound Authorization header. When a Foundry agent invokes a tool, -// the Foundry data plane calls the MCP server with that header, the server validates it, and the -// tool runs. +// Topology (publish mode / aspire deploy): +// +// mcp-server is deployed to Azure Container Apps with external HTTPS ingress, so the Foundry +// data plane can reach it directly at the ACA FQDN. The dev tunnel is omitted from the model +// (it's a run-mode helper only). // -// In `aspire start` (local) mode the Foundry data plane lives in Azure and cannot reach -// http://localhost:* MCP servers, so end-to-end tool invocations only fire after `aspire deploy`. -// The local run still validates that all resources start healthy, the MCP server enforces auth, -// and `aspire publish` produces correct artifacts (bicep + compose). +// Caveat (Foundry data-plane limitation, December 2025): the inline `authorization` / `headers` +// fields that `ResponseTool.CreateMcpTool(...)` writes into the toolbox version JSON are NOT +// currently honored by the Foundry toolbox proxy when it makes outbound calls to the MCP server. +// The documented auth surface for toolbox-deployed MCP tools is `project_connection_id` +// referencing a Foundry project connection that holds the credential +// (see https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/toolbox?pivots=javascript). +// The .NET `ResponseTool.CreateMcpTool` overload doesn't expose `project_connection_id` yet, so +// this sample's bearer-auth round trip currently 401s when invoked through a deployed toolbox. +// The AppHost wiring is still correct (the toolbox version is created with the expected fields and +// the MCP server enforces auth correctly when called directly), but a full Foundry-agent->MCP +// round trip via the toolbox is blocked until the Foundry SDK / data plane closes that gap. import { createBuilder, refExpr, FoundryModels } from './.aspire/modules/aspire.mjs'; const builder = await createBuilder(); +const ec = await builder.executionContext(); +const isPublishMode = await ec.isPublishMode(); + const bearerToken = await builder.addParameter('mcp-bearer-token', { secret: true }); -const mcpServer = await builder +// In publish mode we need an explicit Azure Container Apps environment so the NodeApp below +// has somewhere to be deployed. (The Foundry project's own ContainerRegistry isn't a full ACA +// env, only a registry, so without this the deploy pipeline silently skips the NodeApp.) +let cae; +if (isPublishMode) { + cae = await builder.addAzureContainerAppEnvironment('cae'); +} + +// Build the MCP server. In publish mode we mark the http endpoint as external so Azure Container +// Apps provisions a public HTTPS ingress, which is how the Foundry data plane in Azure reaches +// the server. In run mode the endpoint stays local and is exposed via a dev tunnel below. +let mcpServer = builder .addNodeApp('mcp-server', './mcp-server', 'src/server.ts') .withRunScript('start') .withHttpEndpoint({ env: 'PORT' }) .withEnvironment('MCP_BEARER_TOKEN', bearerToken); +if (isPublishMode) { + // Pin the NodeApp to the ACA env so the deploy pipeline knows where to host it (the Foundry + // project also implements IAzureComputeEnvironmentResource, so without an explicit binding + // the pipeline refuses to pick between them). + if (cae) { + mcpServer = mcpServer.withComputeEnvironment(cae); + } + mcpServer = mcpServer.withExternalHttpEndpoints(); +} + +const mcpServerResource = await mcpServer; +const mcpEndpoint = mcpServerResource.getEndpoint('http'); + +// Resolve the public-from-Foundry URL for the MCP server. In publish mode this is the ACA ingress +// FQDN; in run mode it's a dev tunnel front-end. +let publicMcpEndpoint; +if (isPublishMode) { + // Awaited because refExpr below needs a resolved handle, not a Promise. + publicMcpEndpoint = await mcpEndpoint; +} else { + // Anonymous tunnel so Foundry can reach it without tunnel-level auth; the MCP server's bearer + // middleware is still the gate that actually protects tool invocations. + const mcpTunnel = await builder.addDevTunnel('mcp-tunnel', { allowAnonymous: true }); + await mcpTunnel.withTunnelReferenceAnonymous(mcpEndpoint, true); + publicMcpEndpoint = await mcpTunnel.getTunnelEndpoint(mcpEndpoint); +} + const foundry = await builder.addFoundry('foundry'); const project = await foundry.addProject('project'); @@ -48,10 +100,10 @@ const toolbox = await project.addToolbox('field-tools', { version: 'v1' }); // A built-in Foundry tool, mainly to show the toolbox can mix Foundry-managed and custom tools. await toolbox.withWebSearchTool(); -// The custom MCP tool. The Foundry data plane calls mcpServer over HTTP and sends the bearer -// token in the Authorization header, so the same parameter we gave the MCP server above also -// flows out from Foundry as auth. -await toolbox.withMcpTool('directory', mcpServer.getEndpoint('http'), { +// The custom MCP tool. The MCP server mounts at /mcp (per the spec's conventional path), so the +// base URL needs that suffix appended via refExpr. The Foundry data plane calls the resulting +// public URL over HTTPS and sends the bearer token in the Authorization header. +await toolbox.withMcpTool('directory', refExpr`${publicMcpEndpoint}/mcp`, { authorizationToken: refExpr`${bearerToken}`, headers: { // Custom tracing/tenant header so operators can correlate Foundry-initiated calls in the diff --git a/playground/FoundryAgentToolboxTs/aspire.config.json b/playground/FoundryAgentToolboxTs/aspire.config.json index 153111e5937..bc7f2a13321 100644 --- a/playground/FoundryAgentToolboxTs/aspire.config.json +++ b/playground/FoundryAgentToolboxTs/aspire.config.json @@ -5,6 +5,8 @@ }, "packages": { "Aspire.Hosting.Foundry": "13.5.0-dev", - "Aspire.Hosting.JavaScript": "13.5.0-dev" + "Aspire.Hosting.JavaScript": "13.5.0-dev", + "Aspire.Hosting.DevTunnels": "13.5.0-dev", + "Aspire.Hosting.Azure.AppContainers": "13.5.0-dev" } } diff --git a/playground/FoundryAgentToolboxTs/mcp-server/src/server.ts b/playground/FoundryAgentToolboxTs/mcp-server/src/server.ts index a2b619de469..fb280cc6d64 100644 --- a/playground/FoundryAgentToolboxTs/mcp-server/src/server.ts +++ b/playground/FoundryAgentToolboxTs/mcp-server/src/server.ts @@ -104,12 +104,26 @@ app.get('/health', (_req, res) => { res.status(200).json({ status: 'ok' }); }); +// TEMPORARY: log inbound auth header presence so we can see in the ACA log stream whether the +// Foundry data plane forwards the Authorization header set by the toolbox. Masked so the secret +// doesn't actually leave the container. Remove before committing. +function maskToken(value: string | undefined): string { + if (!value) return ''; + const stripped = value.replace(/^Bearer\s+/i, ''); + if (stripped.length <= 8) return 'Bearer ***'; + return `Bearer ${stripped.slice(0, 4)}...${stripped.slice(-4)}`; +} + // Bearer token middleware. The token comes from MCP_BEARER_TOKEN; the AppHost forwards the same // value to the Toolbox so its outbound calls authenticate cleanly. app.use((req, res, next) => { const auth = req.header('authorization') ?? ''; - const expected = `Bearer ${TOKEN}`; - if (auth !== expected) { + console.log( + `[auth] ${req.method} ${req.path} auth=${maskToken(auth)} ` + + `ua=${req.header('user-agent') ?? ''} ` + + `src=${req.header('x-app-source') ?? req.header('x-ms-source') ?? ''}` + ); + if (auth !== `Bearer ${TOKEN}`) { res.status(401).json({ error: 'unauthorized', message: 'Missing or invalid bearer token.' }); return; } diff --git a/playground/FoundryAgentToolboxTs/scripts/invoke-toolbox.mjs b/playground/FoundryAgentToolboxTs/scripts/invoke-toolbox.mjs new file mode 100644 index 00000000000..b28f2953387 --- /dev/null +++ b/playground/FoundryAgentToolboxTs/scripts/invoke-toolbox.mjs @@ -0,0 +1,82 @@ +// Triggers a real Foundry data-plane call against the configured toolbox. +// Verifies that bearer auth and the dev-tunnel URL round-trip correctly by +// creating a one-shot agent whose only tool is the deployed toolbox. +// +// Run: node scripts/invoke-toolbox.mjs +// PROJECT_ENDPOINT https://.services.ai.azure.com/api/projects/ +// TOOLBOX_NAME field-tools (matches the Aspire resource name) +// TOOLBOX_VERSION optional; if omitted, the latest version is used +// MODEL chat (the model deployment configured in the AppHost) +// PROMPT override the user prompt; defaults to a directory lookup + +import { AgentsClient } from '@azure/ai-agents'; +import { DefaultAzureCredential } from '@azure/identity'; + +const projectEndpoint = process.env.PROJECT_ENDPOINT; +if (!projectEndpoint) { + console.error('PROJECT_ENDPOINT is required.'); + process.exit(1); +} + +const toolboxName = process.env.TOOLBOX_NAME ?? 'field-tools'; +const toolboxVersion = process.env.TOOLBOX_VERSION; +const modelName = process.env.MODEL ?? 'chat'; +const prompt = process.env.PROMPT ?? 'Use the lookup_employee tool to look up Ada Lovelace and tell me their role and department.'; + +const client = new AgentsClient(projectEndpoint, new DefaultAzureCredential()); + +const toolboxRef = toolboxVersion + ? `${projectEndpoint.replace(/\/+$/, '')}/toolboxes/${toolboxName}/versions/${toolboxVersion}/mcp?api-version=v1` + : `${projectEndpoint.replace(/\/+$/, '')}/toolboxes/${toolboxName}/mcp?api-version=v1`; + +console.log('Project endpoint :', projectEndpoint); +console.log('Toolbox MCP URL :', toolboxRef); +console.log('Model :', modelName); +console.log('Prompt :', prompt); + +const agent = await client.createAgent(modelName, { + name: `toolbox-smoke-${Date.now()}`, + instructions: 'You are a helpful assistant. Call tools when the user asks for information that they expose.', + tools: [{ + type: 'mcp', + // Identifier the MCP tool will appear under in the run; also used to attach toolResources below. + serverLabel: 'aspire_toolbox', + serverUrl: toolboxRef + }] +}); +console.log('Created agent :', agent.id); + +const thread = await client.threads.create(); +console.log('Created thread :', thread.id); + +await client.messages.create(thread.id, 'user', prompt); +console.log('Posted prompt.'); + +const run = await client.runs.createAndPoll(thread.id, agent.id, { + // The toolbox already carries its own bearer/headers from the AppHost wiring, so we only need + // to set requireApproval here so the run does not pause for user approval on each tool call. + toolResources: { + mcp: [{ + serverLabel: 'aspire_toolbox', + headers: {}, + requireApproval: 'never' + }] + } +}); +console.log('Run status :', run.status); +console.log('Run last_error :', JSON.stringify(run.lastError ?? null)); + +if (run.status === 'completed') { + const messages = client.messages.list(thread.id, { order: 'desc' }); + for await (const msg of messages) { + if (msg.role === 'assistant') { + console.log('\nAssistant >', JSON.stringify(msg.content, null, 2)); + break; + } + } +} + +console.log('\nCleaning up agent and thread...'); +await client.deleteAgent(agent.id); +await client.threads.delete(thread.id); +console.log('Done.'); diff --git a/playground/FoundryAgentToolboxTs/scripts/package-lock.json b/playground/FoundryAgentToolboxTs/scripts/package-lock.json new file mode 100644 index 00000000000..b28588d51a7 --- /dev/null +++ b/playground/FoundryAgentToolboxTs/scripts/package-lock.json @@ -0,0 +1,905 @@ +{ + "name": "toolbox-smoke", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "toolbox-smoke", + "version": "1.0.0", + "dependencies": { + "@azure/ai-agents": "1.2.0-beta.2", + "@azure/ai-projects": "2.0.0-beta.5", + "@azure/identity": "^4.13.1" + } + }, + "node_modules/@azure-rest/core-client": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@azure-rest/core-client/-/core-client-2.6.0.tgz", + "integrity": "sha512-iuFKDm8XPzNxPfRjhyU5/xKZmcRDzSuEghXDHHk4MjBV/wFL34GmYVBZnn9wmuoLBeS1qAw9ceMdaeJBPcB1QQ==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/ai-agents": { + "version": "1.2.0-beta.2", + "resolved": "https://registry.npmjs.org/@azure/ai-agents/-/ai-agents-1.2.0-beta.2.tgz", + "integrity": "sha512-U1aGWKr9r3Rh/bVfga3TM9ZjdSoHNPokmu5vGXnIk7zPyKtsqKKOIH3u6KnMGviHPxrB05nG8wtWubNwUeQ4lg==", + "license": "MIT", + "dependencies": { + "@azure-rest/core-client": "^2.1.0", + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.6.0", + "@azure/core-lro": "^3.0.0", + "@azure/core-paging": "^1.5.0", + "@azure/core-rest-pipeline": "^1.5.0", + "@azure/core-sse": "^2.1.3", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.9.0", + "@azure/logger": "^1.1.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/ai-projects": { + "version": "2.0.0-beta.5", + "resolved": "https://registry.npmjs.org/@azure/ai-projects/-/ai-projects-2.0.0-beta.5.tgz", + "integrity": "sha512-+K1CkPJIBTlEo76sjYCB+ZOXbOldoJPJFUMv8mEO9nzvlPMeGR1cO1nIClY+fdOvuB/V0wPD1QJzXSDVeBfMDA==", + "license": "MIT", + "dependencies": { + "@azure-rest/core-client": "^2.1.0", + "@azure/abort-controller": "^2.1.2", + "@azure/ai-agents": "1.1.0", + "@azure/arm-cognitiveservices": "7.6.0", + "@azure/core-auth": "^1.6.0", + "@azure/core-lro": "^3.1.0", + "@azure/core-paging": "^1.5.0", + "@azure/core-rest-pipeline": "^1.5.0", + "@azure/core-sse": "^2.1.3", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.9.0", + "@azure/identity": "^4.13.0", + "@azure/logger": "^1.1.4", + "@azure/storage-blob": "^12.26.0", + "openai": "^6.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/ai-projects/node_modules/@azure/ai-agents": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@azure/ai-agents/-/ai-agents-1.1.0.tgz", + "integrity": "sha512-i8HFA7ql18t/otGrRfTWNOE5HgJf/RqedV3VNbFav5z9iTSexf8k4EeWOb/IWWaCsq0z/S7mihdGPAluPs+nXQ==", + "license": "MIT", + "dependencies": { + "@azure-rest/core-client": "^2.1.0", + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.6.0", + "@azure/core-lro": "^3.0.0", + "@azure/core-paging": "^1.5.0", + "@azure/core-rest-pipeline": "^1.5.0", + "@azure/core-sse": "^2.1.3", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.9.0", + "@azure/logger": "^1.1.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/arm-cognitiveservices": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@azure/arm-cognitiveservices/-/arm-cognitiveservices-7.6.0.tgz", + "integrity": "sha512-/NYRy9mM/b2I0EnstncAI9DpqcAJQWyBS8KYuAeIV6XPzoSeeRsOeiCTXVD3fBdMoO2D5sKtCTRYAP1NwlNoLA==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.6.0", + "@azure/core-client": "^1.7.0", + "@azure/core-lro": "^2.5.4", + "@azure/core-paging": "^1.2.0", + "@azure/core-rest-pipeline": "^1.14.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/arm-cognitiveservices/node_modules/@azure/core-lro": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", + "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-util": "^1.2.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz", + "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-http-compat": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.4.0.tgz", + "integrity": "sha512-f1P96IB399YiN2ARYHP7EpZi3Bf3wH4SN2lGzrw7JVwm7bbsVYtf2iKSBwTywD2P62NOPZGHFSZi+6jjb75JuA==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@azure/core-client": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0" + } + }, + "node_modules/@azure/core-lro": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-3.3.1.tgz", + "integrity": "sha512-bulm3klLqIAhzI3iQMYQ42i+V9EnevScsHdI9amFfjaw6OJqPBK1038cq5qachoKV3yt/iQQEDittHmZW2aSuA==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-paging": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.6.2.tgz", + "integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.23.0.tgz", + "integrity": "sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-sse": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@azure/core-sse/-/core-sse-2.3.0.tgz", + "integrity": "sha512-jKhPpdDbVS5GlpadSKIC7V6Q4P2vEcwXi1c4CLTXs01Q/PAITES9v5J/S73+RtCMqQpsX0jGa2yPWwXi9JzdgA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-xml": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@azure/core-xml/-/core-xml-1.5.1.tgz", + "integrity": "sha512-xcNRHqCoSp4AunOALEae6A8f3qATb83gSrm31Iqb01OzblvC3/W/bfXozcq78EzIdzZzuH1bZ2NvRR0TdX709w==", + "license": "MIT", + "dependencies": { + "fast-xml-parser": "^5.5.9", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", + "integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^5.5.0", + "@azure/msal-node": "^5.1.0", + "open": "^10.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", + "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.11.0.tgz", + "integrity": "sha512-zkGNYS3TwY8lUpPIafAmsFCYZbgFixY9y/LZB9GUg0IILoHTqpN26j5OrkL1AQThh/YdZsawe4iWXfp85lFVxg==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.6.2" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.6.2", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.6.2.tgz", + "integrity": "sha512-hQjjsekAjB00cM1EmatWJlzhEoK2Qhz7Rj5gvM6tYf8iL7RM3tkxlpU9fG0+ofkulzg9AEEA6dIEnSmDr5ZqUA==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.2.2.tgz", + "integrity": "sha512-toS+2AePxqyzb0YOKttDOOiSl3jrkK9aiqIvpurpis0O34QcIS5gToqrgT39p04Dpxw3YoUU0lxJKTpSFFfA6Q==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.6.2", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@azure/storage-blob": { + "version": "12.31.0", + "resolved": "https://registry.npmjs.org/@azure/storage-blob/-/storage-blob-12.31.0.tgz", + "integrity": "sha512-DBgNv10aCSxopt92DkTDD0o9xScXeBqPKGmR50FPZQaEcH4JLQ+GEOGEDv19V5BMkB7kxr+m4h6il/cCDPvmHg==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.3", + "@azure/core-http-compat": "^2.2.0", + "@azure/core-lro": "^2.2.0", + "@azure/core-paging": "^1.6.2", + "@azure/core-rest-pipeline": "^1.19.1", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.11.0", + "@azure/core-xml": "^1.4.5", + "@azure/logger": "^1.1.4", + "@azure/storage-common": "^12.3.0", + "events": "^3.0.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/storage-blob/node_modules/@azure/core-lro": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", + "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-util": "^1.2.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/storage-common": { + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/@azure/storage-common/-/storage-common-12.3.0.tgz", + "integrity": "sha512-/OFHhy86aG5Pe8dP5tsp+BuJ25JOAl9yaMU3WZbkeoiFMHFtJ7tu5ili7qEdBXNW9G5lDB19trwyI6V49F/8iQ==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.9.0", + "@azure/core-http-compat": "^2.2.0", + "@azure/core-rest-pipeline": "^1.19.1", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.1.4", + "events": "^3.3.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@nodable/entities": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.1.tgz", + "integrity": "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.5.tgz", + "integrity": "sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw==", + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.8.0.tgz", + "integrity": "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.2.0", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.3.0", + "xml-naming": "^0.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openai": { + "version": "6.39.1", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.39.1.tgz", + "integrity": "sha512-z3dO9fEWOXBzlXynVb/xZ/tujzUjFWQWn3C0n0mw6Vo0zJTbEkaN4b2cLWjhJ6haJQx8LlREoafHRl+Gu/Hl+A==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + } + } +} diff --git a/playground/FoundryAgentToolboxTs/scripts/package.json b/playground/FoundryAgentToolboxTs/scripts/package.json new file mode 100644 index 00000000000..e997e03d86b --- /dev/null +++ b/playground/FoundryAgentToolboxTs/scripts/package.json @@ -0,0 +1,11 @@ +{ + "name": "toolbox-smoke", + "private": true, + "version": "1.0.0", + "type": "module", + "dependencies": { + "@azure/ai-projects": "2.0.0-beta.5", + "@azure/ai-agents": "1.2.0-beta.2", + "@azure/identity": "^4.13.1" + } +} From 76ccaee837ccf2ddd6b7515d6f34208a9108ff9f Mon Sep 17 00:00:00 2001 From: tommasodotnet Date: Tue, 1 Sep 2026 18:19:26 +0200 Subject: [PATCH 08/30] Harden Foundry Toolbox deployment Add ownership-aware, idempotent Toolbox version reconciliation; remove unsupported inline MCP authentication; add unit, polyglot, and Azure deployment coverage; and remove the temporary authentication playground. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../FoundryAgentBasic.AppHost/AppHost.cs | 2 +- playground/FoundryAgentToolboxTs/NuGet.config | 24 - playground/FoundryAgentToolboxTs/README.md | 79 - playground/FoundryAgentToolboxTs/apphost.mts | 115 - .../FoundryAgentToolboxTs/aspire.config.json | 12 - .../mcp-server/package-lock.json | 2210 ----------------- .../mcp-server/package.json | 23 - .../mcp-server/src/server.ts | 218 -- .../mcp-server/tsconfig.json | 20 - .../FoundryAgentToolboxTs/package-lock.json | 578 ----- playground/FoundryAgentToolboxTs/package.json | 20 - .../scripts/invoke-toolbox.mjs | 82 - .../scripts/package-lock.json | 905 ------- .../scripts/package.json | 11 - .../tsconfig.apphost.json | 20 - .../FoundryAgentToolboxTs/tsconfig.json | 21 - .../FoundryToolboxBuilderExtensions.cs | 90 +- .../Toolbox/FoundryToolboxMcpToolOptions.cs | 35 - .../Toolbox/FoundryToolboxReconciler.cs | 366 +++ .../Toolbox/FoundryToolboxResource.cs | 387 ++- .../Toolbox/FoundryToolboxToolDefinition.cs | 139 +- .../FoundryHostedAgentDeploymentTests.cs | 127 + .../FoundryToolboxReconcilerTests.cs | 248 ++ .../ToolboxTests.cs | 80 +- .../TypeScript/apphost.mts | 3 +- 25 files changed, 1039 insertions(+), 4776 deletions(-) delete mode 100644 playground/FoundryAgentToolboxTs/NuGet.config delete mode 100644 playground/FoundryAgentToolboxTs/README.md delete mode 100644 playground/FoundryAgentToolboxTs/apphost.mts delete mode 100644 playground/FoundryAgentToolboxTs/aspire.config.json delete mode 100644 playground/FoundryAgentToolboxTs/mcp-server/package-lock.json delete mode 100644 playground/FoundryAgentToolboxTs/mcp-server/package.json delete mode 100644 playground/FoundryAgentToolboxTs/mcp-server/src/server.ts delete mode 100644 playground/FoundryAgentToolboxTs/mcp-server/tsconfig.json delete mode 100644 playground/FoundryAgentToolboxTs/package-lock.json delete mode 100644 playground/FoundryAgentToolboxTs/package.json delete mode 100644 playground/FoundryAgentToolboxTs/scripts/invoke-toolbox.mjs delete mode 100644 playground/FoundryAgentToolboxTs/scripts/package-lock.json delete mode 100644 playground/FoundryAgentToolboxTs/scripts/package.json delete mode 100644 playground/FoundryAgentToolboxTs/tsconfig.apphost.json delete mode 100644 playground/FoundryAgentToolboxTs/tsconfig.json delete mode 100644 src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxMcpToolOptions.cs create mode 100644 src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReconciler.cs create mode 100644 tests/Aspire.Hosting.Foundry.Tests/FoundryToolboxReconcilerTests.cs diff --git a/playground/FoundryAgentBasic/FoundryAgentBasic.AppHost/AppHost.cs b/playground/FoundryAgentBasic/FoundryAgentBasic.AppHost/AppHost.cs index 509eb7785ac..e6cb64fb9e2 100644 --- a/playground/FoundryAgentBasic/FoundryAgentBasic.AppHost/AppHost.cs +++ b/playground/FoundryAgentBasic/FoundryAgentBasic.AppHost/AppHost.cs @@ -9,7 +9,7 @@ // Add a Foundry Toolbox with a single WebSearch tool. The toolbox is created on the Foundry data // plane at deploy time via AgentToolboxes.CreateToolboxVersionAsync. -project.AddToolbox("field-tools", t => t.Version = "v1") +project.AddToolbox("field-tools") .WithWebSearchTool(); builder.Build().Run(); diff --git a/playground/FoundryAgentToolboxTs/NuGet.config b/playground/FoundryAgentToolboxTs/NuGet.config deleted file mode 100644 index d95cc61860d..00000000000 --- a/playground/FoundryAgentToolboxTs/NuGet.config +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/playground/FoundryAgentToolboxTs/README.md b/playground/FoundryAgentToolboxTs/README.md deleted file mode 100644 index 25e8262e065..00000000000 --- a/playground/FoundryAgentToolboxTs/README.md +++ /dev/null @@ -1,79 +0,0 @@ -# FoundryAgentToolboxTs - -End-to-end sample of an Aspire **TypeScript AppHost** wiring a Foundry Agent + **Foundry Toolbox** to a **custom MCP server** that requires bearer-token authentication. - -## What it shows - -- TypeScript AppHost (`apphost.mts`) using `Aspire.Hosting.Foundry` and `Aspire.Hosting.JavaScript`. -- A custom MCP server (Node + Express + `@modelcontextprotocol/sdk`) implementing the streamable HTTP transport and validating an `Authorization: Bearer ` header. -- A `mcp-bearer-token` parameter wired into **both** sides of the call: - - Into the MCP server as the `MCP_BEARER_TOKEN` env var (for validation). - - Into the Toolbox `withMcpTool(...)` definition as the outbound `authorizationToken` (forwarded by the Foundry data plane as the `Authorization` header). -- An extra `x-app-source` custom header on the MCP tool to demonstrate the headers feature. - -## Topology - -``` -mcp-bearer-token (parameter, secret) - | - +--> mcp-server (Node) - | validates Authorization: Bearer - | - +--> foundry.project.field-tools toolbox - .withWebSearchTool() - .withMcpTool('directory', mcpServer http endpoint, - { authorizationToken: refExpr`${bearerToken}`, - headers: { 'x-app-source': refExpr`foundry-toolbox-ts-sample` } }) -``` - -## Run locally - -> **Pre-release wiring.** This playground depends on the polyglot `authorizationToken` / `headers` -> options added in `Aspire.Hosting.Foundry` by PR #17742, which is not yet in a shipped package. -> Until that PR ships, the local `NuGet.config` here points at `../../artifacts/packages/Debug/Shipping` -> and `aspire.config.json` pins both Foundry / JavaScript hosting packages to `13.5.0-dev`. Before -> running the sample for the first time, pack the in-repo packages: -> -> ```bash -> # from the repo root -> dotnet pack src/Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.csproj -c Debug --no-restore -> dotnet pack src/Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.csproj -c Debug --no-restore -> ``` -> -> Once #17742 ships in a release, delete `NuGet.config` here and set both packages back to `""` in -> `aspire.config.json` (matching `playground/TypeScriptAppHost/aspire.config.json`). - -```bash -cd playground/FoundryAgentToolboxTs/mcp-server && npm install -cd .. && aspire start -``` - -The first time you run it, the CLI prompts for the `mcp-bearer-token` value. Use any non-empty string; the same value flows to the MCP server and to the toolbox tool definition. - -You can manually verify the auth behavior: - -```bash -TOKEN= -ENDPOINT=$(curl -s http://localhost:5252/health) # just to confirm the server is up -curl -i http://localhost:5252/mcp # 401 (no bearer) -curl -i -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}},"id":1}' \ - http://localhost:5252/mcp # initializes a session -``` - -The actual MCP endpoint port is assigned by Aspire, look it up in the dashboard for the `mcp-server` resource. - -## Important caveat - -The **Foundry data plane** is what invokes the MCP server when an agent calls the `directory` tool. The data plane runs in Azure, so it cannot reach `http://localhost:*` MCP endpoints. In `aspire start` mode you can: - -- Verify all resources are healthy. -- Verify the MCP server rejects unauthenticated requests and accepts authenticated ones. -- Inspect the generated Foundry toolbox manifest (via `aspire publish`). - -For a real end-to-end tool invocation from a Foundry agent, run `aspire publish` (or `aspire deploy`) so the MCP server runs on Azure Container Apps with public ingress. - -## Tools exposed by the MCP server - -- `lookup_employee(name)`: returns a fake directory record for one of a handful of names. -- `record_note(subject, body)`: records an in-memory note and returns the assigned id. diff --git a/playground/FoundryAgentToolboxTs/apphost.mts b/playground/FoundryAgentToolboxTs/apphost.mts deleted file mode 100644 index b6213fab99e..00000000000 --- a/playground/FoundryAgentToolboxTs/apphost.mts +++ /dev/null @@ -1,115 +0,0 @@ -// FoundryAgentToolboxTs sample -// -// Demonstrates wiring a Foundry Agent that calls a custom MCP server via the Foundry Toolbox, -// from a TypeScript AppHost, where the MCP server requires bearer-token authentication. -// -// Topology (run mode): -// -// mcp-bearer-token (parameter, secret) -// | -// +--> mcp-server (Node, Express, @modelcontextprotocol/sdk) -// | validates Authorization: Bearer on /mcp -// | -// mcp-tunnel (dev tunnel, anonymous public ingress) -// | exposes mcp-server's http endpoint at https://.devtunnels.ms -// | -// +--> foundry.project.field-tools toolbox -// .withMcpTool('directory', refExpr`${publicEndpoint}/mcp`, -// { authorizationToken: refExpr`${bearerToken}`, headers: { ... } }) -// -// Topology (publish mode / aspire deploy): -// -// mcp-server is deployed to Azure Container Apps with external HTTPS ingress, so the Foundry -// data plane can reach it directly at the ACA FQDN. The dev tunnel is omitted from the model -// (it's a run-mode helper only). -// -// Caveat (Foundry data-plane limitation, December 2025): the inline `authorization` / `headers` -// fields that `ResponseTool.CreateMcpTool(...)` writes into the toolbox version JSON are NOT -// currently honored by the Foundry toolbox proxy when it makes outbound calls to the MCP server. -// The documented auth surface for toolbox-deployed MCP tools is `project_connection_id` -// referencing a Foundry project connection that holds the credential -// (see https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/toolbox?pivots=javascript). -// The .NET `ResponseTool.CreateMcpTool` overload doesn't expose `project_connection_id` yet, so -// this sample's bearer-auth round trip currently 401s when invoked through a deployed toolbox. -// The AppHost wiring is still correct (the toolbox version is created with the expected fields and -// the MCP server enforces auth correctly when called directly), but a full Foundry-agent->MCP -// round trip via the toolbox is blocked until the Foundry SDK / data plane closes that gap. - -import { createBuilder, refExpr, FoundryModels } from './.aspire/modules/aspire.mjs'; - -const builder = await createBuilder(); - -const ec = await builder.executionContext(); -const isPublishMode = await ec.isPublishMode(); - -const bearerToken = await builder.addParameter('mcp-bearer-token', { secret: true }); - -// In publish mode we need an explicit Azure Container Apps environment so the NodeApp below -// has somewhere to be deployed. (The Foundry project's own ContainerRegistry isn't a full ACA -// env, only a registry, so without this the deploy pipeline silently skips the NodeApp.) -let cae; -if (isPublishMode) { - cae = await builder.addAzureContainerAppEnvironment('cae'); -} - -// Build the MCP server. In publish mode we mark the http endpoint as external so Azure Container -// Apps provisions a public HTTPS ingress, which is how the Foundry data plane in Azure reaches -// the server. In run mode the endpoint stays local and is exposed via a dev tunnel below. -let mcpServer = builder - .addNodeApp('mcp-server', './mcp-server', 'src/server.ts') - .withRunScript('start') - .withHttpEndpoint({ env: 'PORT' }) - .withEnvironment('MCP_BEARER_TOKEN', bearerToken); - -if (isPublishMode) { - // Pin the NodeApp to the ACA env so the deploy pipeline knows where to host it (the Foundry - // project also implements IAzureComputeEnvironmentResource, so without an explicit binding - // the pipeline refuses to pick between them). - if (cae) { - mcpServer = mcpServer.withComputeEnvironment(cae); - } - mcpServer = mcpServer.withExternalHttpEndpoints(); -} - -const mcpServerResource = await mcpServer; -const mcpEndpoint = mcpServerResource.getEndpoint('http'); - -// Resolve the public-from-Foundry URL for the MCP server. In publish mode this is the ACA ingress -// FQDN; in run mode it's a dev tunnel front-end. -let publicMcpEndpoint; -if (isPublishMode) { - // Awaited because refExpr below needs a resolved handle, not a Promise. - publicMcpEndpoint = await mcpEndpoint; -} else { - // Anonymous tunnel so Foundry can reach it without tunnel-level auth; the MCP server's bearer - // middleware is still the gate that actually protects tool invocations. - const mcpTunnel = await builder.addDevTunnel('mcp-tunnel', { allowAnonymous: true }); - await mcpTunnel.withTunnelReferenceAnonymous(mcpEndpoint, true); - publicMcpEndpoint = await mcpTunnel.getTunnelEndpoint(mcpEndpoint); -} - -const foundry = await builder.addFoundry('foundry'); -const project = await foundry.addProject('project'); - -// Chat model the Foundry-side agent will use. The toolbox tools are visible to any agent in the -// project that opts into the toolbox. -const _chat = await project.addModelDeployment('chat', FoundryModels.OpenAI.Gpt41Mini); - -const toolbox = await project.addToolbox('field-tools', { version: 'v1' }); - -// A built-in Foundry tool, mainly to show the toolbox can mix Foundry-managed and custom tools. -await toolbox.withWebSearchTool(); - -// The custom MCP tool. The MCP server mounts at /mcp (per the spec's conventional path), so the -// base URL needs that suffix appended via refExpr. The Foundry data plane calls the resulting -// public URL over HTTPS and sends the bearer token in the Authorization header. -await toolbox.withMcpTool('directory', refExpr`${publicMcpEndpoint}/mcp`, { - authorizationToken: refExpr`${bearerToken}`, - headers: { - // Custom tracing/tenant header so operators can correlate Foundry-initiated calls in the - // MCP server logs. - 'x-app-source': refExpr`foundry-toolbox-ts-sample` - } -}); - -await builder.build().run(); diff --git a/playground/FoundryAgentToolboxTs/aspire.config.json b/playground/FoundryAgentToolboxTs/aspire.config.json deleted file mode 100644 index bc7f2a13321..00000000000 --- a/playground/FoundryAgentToolboxTs/aspire.config.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "appHost": { - "path": "apphost.mts", - "language": "typescript/nodejs" - }, - "packages": { - "Aspire.Hosting.Foundry": "13.5.0-dev", - "Aspire.Hosting.JavaScript": "13.5.0-dev", - "Aspire.Hosting.DevTunnels": "13.5.0-dev", - "Aspire.Hosting.Azure.AppContainers": "13.5.0-dev" - } -} diff --git a/playground/FoundryAgentToolboxTs/mcp-server/package-lock.json b/playground/FoundryAgentToolboxTs/mcp-server/package-lock.json deleted file mode 100644 index 4c5d323ef1e..00000000000 --- a/playground/FoundryAgentToolboxTs/mcp-server/package-lock.json +++ /dev/null @@ -1,2210 +0,0 @@ -{ - "name": "directory-mcp-server", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "directory-mcp-server", - "version": "1.0.0", - "dependencies": { - "@modelcontextprotocol/sdk": "^1.18.0", - "express": "^4.21.2", - "zod": "^3.24.1" - }, - "devDependencies": { - "@types/express": "^4.17.21", - "@types/node": "^20.0.0", - "tsx": "^4.19.0", - "typescript": "^5.3.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", - "license": "MIT", - "dependencies": { - "@hono/node-server": "^1.19.9", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", - "license": "MIT", - "dependencies": { - "content-type": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/express": { - "version": "4.17.25", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", - "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "^1" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.19.8", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", - "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "20.19.41", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", - "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", - "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" - } - }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.15.1", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", - "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", - "license": "MIT", - "peer": true, - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.5", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.15.1", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.5.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", - "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", - "license": "MIT", - "dependencies": { - "ip-address": "^10.2.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hono": { - "version": "4.12.23", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", - "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=16.9.0" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" - }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/router/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/router/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/router/node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tsx": { - "version": "4.22.3", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", - "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.28.0" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } - } - } -} diff --git a/playground/FoundryAgentToolboxTs/mcp-server/package.json b/playground/FoundryAgentToolboxTs/mcp-server/package.json deleted file mode 100644 index 9229ec3dbfa..00000000000 --- a/playground/FoundryAgentToolboxTs/mcp-server/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "directory-mcp-server", - "version": "1.0.0", - "description": "Sample MCP server protected by bearer-token auth, called by the Foundry Toolbox over HTTP.", - "type": "module", - "private": true, - "scripts": { - "start": "tsx src/server.ts", - "dev": "tsx src/server.ts", - "build": "tsc" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "^1.18.0", - "express": "^4.21.2", - "zod": "^3.24.1" - }, - "devDependencies": { - "@types/express": "^4.17.21", - "@types/node": "^20.0.0", - "tsx": "^4.19.0", - "typescript": "^5.3.0" - } -} diff --git a/playground/FoundryAgentToolboxTs/mcp-server/src/server.ts b/playground/FoundryAgentToolboxTs/mcp-server/src/server.ts deleted file mode 100644 index fb280cc6d64..00000000000 --- a/playground/FoundryAgentToolboxTs/mcp-server/src/server.ts +++ /dev/null @@ -1,218 +0,0 @@ -// Minimal MCP server protected by a bearer token. The Foundry Toolbox sends the token in the -// Authorization header on every request; the AppHost wires the same token into both this server -// (for validation) and the toolbox MCP tool definition (for outbound auth) via a single shared -// parameter. -// -// Transport: streamable HTTP, mounted at /mcp. -// - POST /mcp - JSON-RPC requests + tool invocations -// - GET /mcp - SSE stream for server-initiated messages -// - DELETE /mcp - explicit session termination -// -// Tools exposed: -// - lookup_employee(name): returns a fake directory record -// - record_note(subject, body): records a note in-memory and returns the assigned id -// -// References: -// - https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http -// - https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/toolbox?pivots=javascript - -import { randomUUID } from 'node:crypto'; -import express, { type Request, type Response } from 'express'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; -import { z } from 'zod'; - -const PORT = Number(process.env.PORT ?? process.env.MCP_PORT ?? 5252); -const TOKEN = process.env.MCP_BEARER_TOKEN; - -if (!TOKEN) { - console.error('MCP_BEARER_TOKEN environment variable is required.'); - process.exit(1); -} - -interface DirectoryEntry { - readonly name: string; - readonly title: string; - readonly office: string; - readonly email: string; -} - -const directory: Record = { - 'ada lovelace': { name: 'Ada Lovelace', title: 'Principal Engineer', office: 'London', email: 'ada@contoso.example' }, - 'grace hopper': { name: 'Grace Hopper', title: 'Distinguished Engineer', office: 'New York', email: 'grace@contoso.example' }, - 'alan turing': { name: 'Alan Turing', title: 'Researcher', office: 'Cambridge', email: 'alan@contoso.example' } -}; - -interface Note { - readonly id: string; - readonly subject: string; - readonly body: string; - readonly createdAt: string; -} - -const notes: Note[] = []; - -function buildServer(): McpServer { - const server = new McpServer( - { name: 'directory-mcp', version: '1.0.0' }, - { capabilities: { tools: {} } }); - - server.registerTool( - 'lookup_employee', - { - description: 'Look up an employee in the company directory by name. Returns title, office, and email.', - inputSchema: { name: z.string().describe('The employee full name to look up.') } - }, - async ({ name }) => { - const entry = directory[name.trim().toLowerCase()]; - if (!entry) { - return { content: [{ type: 'text', text: `No employee named '${name}' found in the directory.` }] }; - } - return { - content: [{ - type: 'text', - text: `Name: ${entry.name}\nTitle: ${entry.title}\nOffice: ${entry.office}\nEmail: ${entry.email}` - }] - }; - }); - - server.registerTool( - 'record_note', - { - description: 'Record a short note for the team. Returns the assigned note id.', - inputSchema: { - subject: z.string().describe('Short subject line.'), - body: z.string().describe('Note body.') - } - }, - async ({ subject, body }) => { - const note: Note = { id: randomUUID(), subject, body, createdAt: new Date().toISOString() }; - notes.push(note); - return { content: [{ type: 'text', text: `Recorded note ${note.id} ('${subject}').` }] }; - }); - - return server; -} - -const app = express(); -app.use(express.json()); - -// Unauthenticated liveness probe so the AppHost can mark the resource healthy without leaking -// the bearer token requirement onto the health-check path. -app.get('/health', (_req, res) => { - res.status(200).json({ status: 'ok' }); -}); - -// TEMPORARY: log inbound auth header presence so we can see in the ACA log stream whether the -// Foundry data plane forwards the Authorization header set by the toolbox. Masked so the secret -// doesn't actually leave the container. Remove before committing. -function maskToken(value: string | undefined): string { - if (!value) return ''; - const stripped = value.replace(/^Bearer\s+/i, ''); - if (stripped.length <= 8) return 'Bearer ***'; - return `Bearer ${stripped.slice(0, 4)}...${stripped.slice(-4)}`; -} - -// Bearer token middleware. The token comes from MCP_BEARER_TOKEN; the AppHost forwards the same -// value to the Toolbox so its outbound calls authenticate cleanly. -app.use((req, res, next) => { - const auth = req.header('authorization') ?? ''; - console.log( - `[auth] ${req.method} ${req.path} auth=${maskToken(auth)} ` + - `ua=${req.header('user-agent') ?? ''} ` + - `src=${req.header('x-app-source') ?? req.header('x-ms-source') ?? ''}` - ); - if (auth !== `Bearer ${TOKEN}`) { - res.status(401).json({ error: 'unauthorized', message: 'Missing or invalid bearer token.' }); - return; - } - next(); -}); - -// One transport per MCP session. The session id arrives in the Mcp-Session-Id header on every -// request after initialization (per the Streamable HTTP spec). -const transports = new Map(); - -async function handleMcpPost(req: Request, res: Response): Promise { - const sessionHeader = req.header('mcp-session-id'); - let transport = sessionHeader ? transports.get(sessionHeader) : undefined; - - if (!transport) { - // Either the very first request (initialize) or a request with a stale/missing session id. - if (!isInitializeRequest(req.body)) { - res.status(400).json({ - jsonrpc: '2.0', - error: { code: -32000, message: 'Bad Request: No valid MCP session.' }, - id: null - }); - return; - } - - transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (id) => { - transports.set(id, transport!); - } - }); - - // Clean up server-side state when the client disconnects so transports doesn't grow unbounded. - transport.onclose = () => { - if (transport!.sessionId) { - transports.delete(transport!.sessionId); - } - }; - - const server = buildServer(); - await server.connect(transport); - } - - await transport.handleRequest(req, res, req.body); -} - -async function handleMcpSessionRequest(req: Request, res: Response): Promise { - const sessionHeader = req.header('mcp-session-id'); - const transport = sessionHeader ? transports.get(sessionHeader) : undefined; - if (!transport) { - res.status(400).send('Invalid or missing Mcp-Session-Id header.'); - return; - } - await transport.handleRequest(req, res); -} - -app.post('/mcp', (req, res) => { - handleMcpPost(req, res).catch((err) => { - console.error('Error handling MCP POST', err); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - error: { code: -32603, message: 'Internal server error' }, - id: null - }); - } - }); -}); - -app.get('/mcp', (req, res) => { - handleMcpSessionRequest(req, res).catch((err) => { - console.error('Error handling MCP GET', err); - if (!res.headersSent) { - res.status(500).send('Internal server error'); - } - }); -}); - -app.delete('/mcp', (req, res) => { - handleMcpSessionRequest(req, res).catch((err) => { - console.error('Error handling MCP DELETE', err); - if (!res.headersSent) { - res.status(500).send('Internal server error'); - } - }); -}); - -app.listen(PORT, () => { - console.log(`Directory MCP server listening on http://0.0.0.0:${PORT}/mcp`); - console.log('Health endpoint: /health'); - console.log('Authorization: Bearer required for /mcp'); -}); diff --git a/playground/FoundryAgentToolboxTs/mcp-server/tsconfig.json b/playground/FoundryAgentToolboxTs/mcp-server/tsconfig.json deleted file mode 100644 index 39f1a1f2789..00000000000 --- a/playground/FoundryAgentToolboxTs/mcp-server/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "strict": true, - "skipLibCheck": true, - "outDir": "./dist", - "rootDir": "./src" - }, - "include": [ - "src/**/*.ts" - ], - "exclude": [ - "node_modules", - "dist" - ] -} diff --git a/playground/FoundryAgentToolboxTs/package-lock.json b/playground/FoundryAgentToolboxTs/package-lock.json deleted file mode 100644 index 4d865afa602..00000000000 --- a/playground/FoundryAgentToolboxTs/package-lock.json +++ /dev/null @@ -1,578 +0,0 @@ -{ - "name": "foundry-agent-toolbox-ts", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "foundry-agent-toolbox-ts", - "version": "1.0.0", - "dependencies": { - "vscode-jsonrpc": "^8.2.0" - }, - "devDependencies": { - "@types/node": "^20.0.0", - "tsx": "^4.19.0", - "typescript": "^5.3.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@types/node": { - "version": "20.19.41", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", - "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/tsx": { - "version": "4.22.3", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", - "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.28.0" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/vscode-jsonrpc": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", - "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - } - } -} diff --git a/playground/FoundryAgentToolboxTs/package.json b/playground/FoundryAgentToolboxTs/package.json deleted file mode 100644 index e2c3f59ca81..00000000000 --- a/playground/FoundryAgentToolboxTs/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "foundry-agent-toolbox-ts", - "version": "1.0.0", - "description": "TypeScript AppHost wiring up a Foundry Agent + Toolbox + custom auth-protected MCP server.", - "type": "module", - "private": true, - "scripts": { - "start": "aspire run", - "build": "tsc --noEmit", - "dev": "tsc --noEmit --watch" - }, - "dependencies": { - "vscode-jsonrpc": "^8.2.0" - }, - "devDependencies": { - "@types/node": "^20.0.0", - "tsx": "^4.19.0", - "typescript": "^5.3.0" - } -} diff --git a/playground/FoundryAgentToolboxTs/scripts/invoke-toolbox.mjs b/playground/FoundryAgentToolboxTs/scripts/invoke-toolbox.mjs deleted file mode 100644 index b28f2953387..00000000000 --- a/playground/FoundryAgentToolboxTs/scripts/invoke-toolbox.mjs +++ /dev/null @@ -1,82 +0,0 @@ -// Triggers a real Foundry data-plane call against the configured toolbox. -// Verifies that bearer auth and the dev-tunnel URL round-trip correctly by -// creating a one-shot agent whose only tool is the deployed toolbox. -// -// Run: node scripts/invoke-toolbox.mjs -// PROJECT_ENDPOINT https://.services.ai.azure.com/api/projects/ -// TOOLBOX_NAME field-tools (matches the Aspire resource name) -// TOOLBOX_VERSION optional; if omitted, the latest version is used -// MODEL chat (the model deployment configured in the AppHost) -// PROMPT override the user prompt; defaults to a directory lookup - -import { AgentsClient } from '@azure/ai-agents'; -import { DefaultAzureCredential } from '@azure/identity'; - -const projectEndpoint = process.env.PROJECT_ENDPOINT; -if (!projectEndpoint) { - console.error('PROJECT_ENDPOINT is required.'); - process.exit(1); -} - -const toolboxName = process.env.TOOLBOX_NAME ?? 'field-tools'; -const toolboxVersion = process.env.TOOLBOX_VERSION; -const modelName = process.env.MODEL ?? 'chat'; -const prompt = process.env.PROMPT ?? 'Use the lookup_employee tool to look up Ada Lovelace and tell me their role and department.'; - -const client = new AgentsClient(projectEndpoint, new DefaultAzureCredential()); - -const toolboxRef = toolboxVersion - ? `${projectEndpoint.replace(/\/+$/, '')}/toolboxes/${toolboxName}/versions/${toolboxVersion}/mcp?api-version=v1` - : `${projectEndpoint.replace(/\/+$/, '')}/toolboxes/${toolboxName}/mcp?api-version=v1`; - -console.log('Project endpoint :', projectEndpoint); -console.log('Toolbox MCP URL :', toolboxRef); -console.log('Model :', modelName); -console.log('Prompt :', prompt); - -const agent = await client.createAgent(modelName, { - name: `toolbox-smoke-${Date.now()}`, - instructions: 'You are a helpful assistant. Call tools when the user asks for information that they expose.', - tools: [{ - type: 'mcp', - // Identifier the MCP tool will appear under in the run; also used to attach toolResources below. - serverLabel: 'aspire_toolbox', - serverUrl: toolboxRef - }] -}); -console.log('Created agent :', agent.id); - -const thread = await client.threads.create(); -console.log('Created thread :', thread.id); - -await client.messages.create(thread.id, 'user', prompt); -console.log('Posted prompt.'); - -const run = await client.runs.createAndPoll(thread.id, agent.id, { - // The toolbox already carries its own bearer/headers from the AppHost wiring, so we only need - // to set requireApproval here so the run does not pause for user approval on each tool call. - toolResources: { - mcp: [{ - serverLabel: 'aspire_toolbox', - headers: {}, - requireApproval: 'never' - }] - } -}); -console.log('Run status :', run.status); -console.log('Run last_error :', JSON.stringify(run.lastError ?? null)); - -if (run.status === 'completed') { - const messages = client.messages.list(thread.id, { order: 'desc' }); - for await (const msg of messages) { - if (msg.role === 'assistant') { - console.log('\nAssistant >', JSON.stringify(msg.content, null, 2)); - break; - } - } -} - -console.log('\nCleaning up agent and thread...'); -await client.deleteAgent(agent.id); -await client.threads.delete(thread.id); -console.log('Done.'); diff --git a/playground/FoundryAgentToolboxTs/scripts/package-lock.json b/playground/FoundryAgentToolboxTs/scripts/package-lock.json deleted file mode 100644 index b28588d51a7..00000000000 --- a/playground/FoundryAgentToolboxTs/scripts/package-lock.json +++ /dev/null @@ -1,905 +0,0 @@ -{ - "name": "toolbox-smoke", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "toolbox-smoke", - "version": "1.0.0", - "dependencies": { - "@azure/ai-agents": "1.2.0-beta.2", - "@azure/ai-projects": "2.0.0-beta.5", - "@azure/identity": "^4.13.1" - } - }, - "node_modules/@azure-rest/core-client": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@azure-rest/core-client/-/core-client-2.6.0.tgz", - "integrity": "sha512-iuFKDm8XPzNxPfRjhyU5/xKZmcRDzSuEghXDHHk4MjBV/wFL34GmYVBZnn9wmuoLBeS1qAw9ceMdaeJBPcB1QQ==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.10.0", - "@azure/core-rest-pipeline": "^1.22.0", - "@azure/core-tracing": "^1.3.0", - "@typespec/ts-http-runtime": "^0.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/abort-controller": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", - "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@azure/ai-agents": { - "version": "1.2.0-beta.2", - "resolved": "https://registry.npmjs.org/@azure/ai-agents/-/ai-agents-1.2.0-beta.2.tgz", - "integrity": "sha512-U1aGWKr9r3Rh/bVfga3TM9ZjdSoHNPokmu5vGXnIk7zPyKtsqKKOIH3u6KnMGviHPxrB05nG8wtWubNwUeQ4lg==", - "license": "MIT", - "dependencies": { - "@azure-rest/core-client": "^2.1.0", - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.6.0", - "@azure/core-lro": "^3.0.0", - "@azure/core-paging": "^1.5.0", - "@azure/core-rest-pipeline": "^1.5.0", - "@azure/core-sse": "^2.1.3", - "@azure/core-tracing": "^1.2.0", - "@azure/core-util": "^1.9.0", - "@azure/logger": "^1.1.4", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/ai-projects": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@azure/ai-projects/-/ai-projects-2.0.0-beta.5.tgz", - "integrity": "sha512-+K1CkPJIBTlEo76sjYCB+ZOXbOldoJPJFUMv8mEO9nzvlPMeGR1cO1nIClY+fdOvuB/V0wPD1QJzXSDVeBfMDA==", - "license": "MIT", - "dependencies": { - "@azure-rest/core-client": "^2.1.0", - "@azure/abort-controller": "^2.1.2", - "@azure/ai-agents": "1.1.0", - "@azure/arm-cognitiveservices": "7.6.0", - "@azure/core-auth": "^1.6.0", - "@azure/core-lro": "^3.1.0", - "@azure/core-paging": "^1.5.0", - "@azure/core-rest-pipeline": "^1.5.0", - "@azure/core-sse": "^2.1.3", - "@azure/core-tracing": "^1.2.0", - "@azure/core-util": "^1.9.0", - "@azure/identity": "^4.13.0", - "@azure/logger": "^1.1.4", - "@azure/storage-blob": "^12.26.0", - "openai": "^6.16.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/ai-projects/node_modules/@azure/ai-agents": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@azure/ai-agents/-/ai-agents-1.1.0.tgz", - "integrity": "sha512-i8HFA7ql18t/otGrRfTWNOE5HgJf/RqedV3VNbFav5z9iTSexf8k4EeWOb/IWWaCsq0z/S7mihdGPAluPs+nXQ==", - "license": "MIT", - "dependencies": { - "@azure-rest/core-client": "^2.1.0", - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.6.0", - "@azure/core-lro": "^3.0.0", - "@azure/core-paging": "^1.5.0", - "@azure/core-rest-pipeline": "^1.5.0", - "@azure/core-sse": "^2.1.3", - "@azure/core-tracing": "^1.2.0", - "@azure/core-util": "^1.9.0", - "@azure/logger": "^1.1.4", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/arm-cognitiveservices": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@azure/arm-cognitiveservices/-/arm-cognitiveservices-7.6.0.tgz", - "integrity": "sha512-/NYRy9mM/b2I0EnstncAI9DpqcAJQWyBS8KYuAeIV6XPzoSeeRsOeiCTXVD3fBdMoO2D5sKtCTRYAP1NwlNoLA==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.6.0", - "@azure/core-client": "^1.7.0", - "@azure/core-lro": "^2.5.4", - "@azure/core-paging": "^1.2.0", - "@azure/core-rest-pipeline": "^1.14.0", - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@azure/arm-cognitiveservices/node_modules/@azure/core-lro": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", - "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.0.0", - "@azure/core-util": "^1.2.0", - "@azure/logger": "^1.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@azure/core-auth": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", - "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-util": "^1.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-client": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz", - "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", - "license": "MIT", - "peer": true, - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.10.0", - "@azure/core-rest-pipeline": "^1.22.0", - "@azure/core-tracing": "^1.3.0", - "@azure/core-util": "^1.13.0", - "@azure/logger": "^1.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-http-compat": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.4.0.tgz", - "integrity": "sha512-f1P96IB399YiN2ARYHP7EpZi3Bf3wH4SN2lGzrw7JVwm7bbsVYtf2iKSBwTywD2P62NOPZGHFSZi+6jjb75JuA==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@azure/core-client": "^1.10.0", - "@azure/core-rest-pipeline": "^1.22.0" - } - }, - "node_modules/@azure/core-lro": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-3.3.1.tgz", - "integrity": "sha512-bulm3klLqIAhzI3iQMYQ42i+V9EnevScsHdI9amFfjaw6OJqPBK1038cq5qachoKV3yt/iQQEDittHmZW2aSuA==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-util": "^1.13.0", - "@azure/logger": "^1.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-paging": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.6.2.tgz", - "integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@azure/core-rest-pipeline": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.23.0.tgz", - "integrity": "sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.10.0", - "@azure/core-tracing": "^1.3.0", - "@azure/core-util": "^1.13.0", - "@azure/logger": "^1.3.0", - "@typespec/ts-http-runtime": "^0.3.4", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-sse": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@azure/core-sse/-/core-sse-2.3.0.tgz", - "integrity": "sha512-jKhPpdDbVS5GlpadSKIC7V6Q4P2vEcwXi1c4CLTXs01Q/PAITES9v5J/S73+RtCMqQpsX0jGa2yPWwXi9JzdgA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-tracing": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", - "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", - "license": "MIT", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-util": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", - "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@typespec/ts-http-runtime": "^0.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-xml": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@azure/core-xml/-/core-xml-1.5.1.tgz", - "integrity": "sha512-xcNRHqCoSp4AunOALEae6A8f3qATb83gSrm31Iqb01OzblvC3/W/bfXozcq78EzIdzZzuH1bZ2NvRR0TdX709w==", - "license": "MIT", - "dependencies": { - "fast-xml-parser": "^5.5.9", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/identity": { - "version": "4.13.1", - "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", - "integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.0.0", - "@azure/core-auth": "^1.9.0", - "@azure/core-client": "^1.9.2", - "@azure/core-rest-pipeline": "^1.17.0", - "@azure/core-tracing": "^1.0.0", - "@azure/core-util": "^1.11.0", - "@azure/logger": "^1.0.0", - "@azure/msal-browser": "^5.5.0", - "@azure/msal-node": "^5.1.0", - "open": "^10.1.0", - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/logger": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", - "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", - "license": "MIT", - "dependencies": { - "@typespec/ts-http-runtime": "^0.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/msal-browser": { - "version": "5.11.0", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.11.0.tgz", - "integrity": "sha512-zkGNYS3TwY8lUpPIafAmsFCYZbgFixY9y/LZB9GUg0IILoHTqpN26j5OrkL1AQThh/YdZsawe4iWXfp85lFVxg==", - "license": "MIT", - "dependencies": { - "@azure/msal-common": "16.6.2" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@azure/msal-common": { - "version": "16.6.2", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.6.2.tgz", - "integrity": "sha512-hQjjsekAjB00cM1EmatWJlzhEoK2Qhz7Rj5gvM6tYf8iL7RM3tkxlpU9fG0+ofkulzg9AEEA6dIEnSmDr5ZqUA==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@azure/msal-node": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.2.2.tgz", - "integrity": "sha512-toS+2AePxqyzb0YOKttDOOiSl3jrkK9aiqIvpurpis0O34QcIS5gToqrgT39p04Dpxw3YoUU0lxJKTpSFFfA6Q==", - "license": "MIT", - "dependencies": { - "@azure/msal-common": "16.6.2", - "jsonwebtoken": "^9.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@azure/storage-blob": { - "version": "12.31.0", - "resolved": "https://registry.npmjs.org/@azure/storage-blob/-/storage-blob-12.31.0.tgz", - "integrity": "sha512-DBgNv10aCSxopt92DkTDD0o9xScXeBqPKGmR50FPZQaEcH4JLQ+GEOGEDv19V5BMkB7kxr+m4h6il/cCDPvmHg==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.9.0", - "@azure/core-client": "^1.9.3", - "@azure/core-http-compat": "^2.2.0", - "@azure/core-lro": "^2.2.0", - "@azure/core-paging": "^1.6.2", - "@azure/core-rest-pipeline": "^1.19.1", - "@azure/core-tracing": "^1.2.0", - "@azure/core-util": "^1.11.0", - "@azure/core-xml": "^1.4.5", - "@azure/logger": "^1.1.4", - "@azure/storage-common": "^12.3.0", - "events": "^3.0.0", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/storage-blob/node_modules/@azure/core-lro": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", - "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.0.0", - "@azure/core-util": "^1.2.0", - "@azure/logger": "^1.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@azure/storage-common": { - "version": "12.3.0", - "resolved": "https://registry.npmjs.org/@azure/storage-common/-/storage-common-12.3.0.tgz", - "integrity": "sha512-/OFHhy86aG5Pe8dP5tsp+BuJ25JOAl9yaMU3WZbkeoiFMHFtJ7tu5ili7qEdBXNW9G5lDB19trwyI6V49F/8iQ==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.9.0", - "@azure/core-http-compat": "^2.2.0", - "@azure/core-rest-pipeline": "^1.19.1", - "@azure/core-tracing": "^1.2.0", - "@azure/core-util": "^1.11.0", - "@azure/logger": "^1.1.4", - "events": "^3.3.0", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@nodable/entities": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.1.tgz", - "integrity": "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/nodable" - } - ], - "license": "MIT" - }, - "node_modules/@typespec/ts-http-runtime": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.5.tgz", - "integrity": "sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw==", - "license": "MIT", - "dependencies": { - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "license": "MIT", - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/default-browser": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", - "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", - "license": "MIT", - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, - "node_modules/fast-xml-builder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.5.0", - "xml-naming": "^0.1.0" - } - }, - "node_modules/fast-xml-parser": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.8.0.tgz", - "integrity": "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "@nodable/entities": "^2.1.0", - "fast-xml-builder": "^1.2.0", - "path-expression-matcher": "^1.5.0", - "strnum": "^2.3.0", - "xml-naming": "^0.1.0" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-wsl": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", - "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/jsonwebtoken": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", - "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", - "license": "MIT", - "dependencies": { - "jws": "^4.0.1", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12", - "npm": ">=6" - } - }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", - "license": "MIT" - }, - "node_modules/lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", - "license": "MIT" - }, - "node_modules/lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", - "license": "MIT" - }, - "node_modules/lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", - "license": "MIT" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "license": "MIT" - }, - "node_modules/lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "license": "MIT" - }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "license": "MIT" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/open": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", - "license": "MIT", - "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/openai": { - "version": "6.39.1", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.39.1.tgz", - "integrity": "sha512-z3dO9fEWOXBzlXynVb/xZ/tujzUjFWQWn3C0n0mw6Vo0zJTbEkaN4b2cLWjhJ6haJQx8LlREoafHRl+Gu/Hl+A==", - "license": "Apache-2.0", - "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/run-applescript": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/strnum": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", - "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/wsl-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", - "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", - "license": "MIT", - "dependencies": { - "is-wsl": "^3.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/xml-naming": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - } - } -} diff --git a/playground/FoundryAgentToolboxTs/scripts/package.json b/playground/FoundryAgentToolboxTs/scripts/package.json deleted file mode 100644 index e997e03d86b..00000000000 --- a/playground/FoundryAgentToolboxTs/scripts/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "toolbox-smoke", - "private": true, - "version": "1.0.0", - "type": "module", - "dependencies": { - "@azure/ai-projects": "2.0.0-beta.5", - "@azure/ai-agents": "1.2.0-beta.2", - "@azure/identity": "^4.13.1" - } -} diff --git a/playground/FoundryAgentToolboxTs/tsconfig.apphost.json b/playground/FoundryAgentToolboxTs/tsconfig.apphost.json deleted file mode 100644 index e81bdc9d399..00000000000 --- a/playground/FoundryAgentToolboxTs/tsconfig.apphost.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "strict": true, - "skipLibCheck": true, - "outDir": "./dist/apphost", - "rootDir": "." - }, - "include": [ - "apphost.mts", - ".aspire/modules/aspire.mts", - ".aspire/modules/base.mts", - ".aspire/modules/transport.mts" - ], - "exclude": ["node_modules"] -} diff --git a/playground/FoundryAgentToolboxTs/tsconfig.json b/playground/FoundryAgentToolboxTs/tsconfig.json deleted file mode 100644 index e6e437b3d63..00000000000 --- a/playground/FoundryAgentToolboxTs/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "strict": true, - "skipLibCheck": true, - "outDir": "./dist", - "rootDir": "." - }, - "include": [ - "apphost.mts", - ".aspire/modules/**/*.mts" - ], - "exclude": [ - "node_modules", - "mcp-server" - ] -} diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs index ee7d399d1b2..2550ccfa3bc 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs @@ -20,9 +20,10 @@ public static class FoundryToolboxBuilderExtensions /// Optional callback used to configure the Toolbox resource. /// A reference to the for the Toolbox resource. /// - /// A new immutable toolbox version is created on the Foundry data plane at deploy time. The - /// property pins the version used by consumers in - /// the MCP endpoint URI; the version produced by the most recent deploy is exposed via + /// 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 /// . /// /// The resource builder. @@ -82,30 +83,50 @@ public static IResourceBuilder WithWebSearchTool( 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 callback used to configure the tool definition. Use this to set - /// or populate - /// when the MCP server requires - /// authentication. - /// /// 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, - Action? configure = null) + string endpoint) { ArgumentException.ThrowIfNullOrEmpty(endpoint); + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri) || + endpointUri.Scheme != Uri.UriSchemeHttps) + { + throw new ArgumentException( + "The MCP endpoint must be an absolute HTTPS URI.", + nameof(endpoint)); + } - return builder.WithMcpTool(name, ReferenceExpression.Create($"{endpoint}"), configure); + return builder.WithMcpTool(name, ReferenceExpression.Create($"{endpointUri.AbsoluteUri}")); } /// @@ -114,24 +135,17 @@ public static IResourceBuilder WithMcpTool( /// The resource builder for the Toolbox. /// The tool name. /// The MCP endpoint. - /// - /// Optional callback used to configure the tool definition. Use this to set - /// or populate - /// when the MCP server requires - /// authentication. - /// /// 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, - Action? configure = null) + EndpointReference endpoint) { ArgumentNullException.ThrowIfNull(endpoint); - return builder.WithMcpTool(name, ReferenceExpression.Create($"{endpoint}"), configure); + return builder.WithMcpTool(name, ReferenceExpression.Create($"{endpoint}")); } /// @@ -143,43 +157,24 @@ public static IResourceBuilder WithMcpTool( /// 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 auth/headers configuration. /// 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) + [AspireUnion(typeof(string), typeof(EndpointReference), typeof(ReferenceExpression))] object endpoint) { ArgumentNullException.ThrowIfNull(endpoint); - // Project the polyglot options onto the same configure callback the .NET overloads use so - // headers/auth wiring stays consistent across surface areas. - Action? configure = options is null - ? null - : tool => - { - if (options.AuthorizationToken is not null) - { - tool.AuthorizationTokenExpression = options.AuthorizationToken; - } - - foreach (var header in options.Headers) - { - tool.Headers[header.Key] = header.Value; - } - }; - return endpoint switch { - string endpointString => builder.WithMcpTool(name, endpointString, configure), - EndpointReference endpointReference => builder.WithMcpTool(name, endpointReference, configure), + string endpointString => builder.WithMcpTool(name, endpointString), + EndpointReference endpointReference => builder.WithMcpTool(name, endpointReference), // 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, configure), + ReferenceExpression endpointExpression => builder.WithMcpTool(name, endpointExpression), _ => throw new ArgumentException("Endpoint must be a string, endpoint reference, or reference expression.", nameof(endpoint)) }; } @@ -218,16 +213,13 @@ public static IResourceBuilder WithAISearchTool( private static IResourceBuilder WithMcpTool( this IResourceBuilder builder, string name, - ReferenceExpression endpointExpression, - Action? configure = null) + ReferenceExpression endpointExpression) { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(name); ArgumentNullException.ThrowIfNull(endpointExpression); - var tool = new FoundryToolboxMcpToolDefinition(name, endpointExpression); - configure?.Invoke(tool); - builder.Resource.AddTool(tool); + builder.Resource.AddTool(new FoundryToolboxMcpToolDefinition(name, endpointExpression)); return builder; } diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxMcpToolOptions.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxMcpToolOptions.cs deleted file mode 100644 index 738114ade36..00000000000 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxMcpToolOptions.cs +++ /dev/null @@ -1,35 +0,0 @@ -// 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; - -/// -/// Options used when adding an MCP tool definition to a Microsoft Foundry Toolbox from a polyglot -/// AppHost. Mirrors the auth/header configuration available via the .NET -/// callback overloads of WithMcpTool. -/// -[AspireDto] -internal sealed class FoundryToolboxMcpToolOptions -{ - /// - /// Gets or sets an optional expression that resolves to the bearer token sent to the MCP - /// server on every request. Use this for MCP servers that require authorization. - /// - /// - /// When set, the resolved value is forwarded to the Foundry data plane and the Foundry agent - /// runtime sends it as the Authorization header (with the standard Bearer scheme) - /// when invoking the MCP server. - /// - public ReferenceExpression? AuthorizationToken { get; set; } - - /// - /// Gets the set of HTTP headers the Foundry agent runtime sends to the MCP server on every - /// request. Header names are matched case-insensitively, mirroring HTTP semantics. Header - /// entries whose value resolves to or an empty string are silently - /// omitted. - /// - public IDictionary Headers { get; init; } = - new Dictionary(StringComparer.OrdinalIgnoreCase); -} diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReconciler.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReconciler.cs new file mode 100644 index 00000000000..d886121005f --- /dev/null +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReconciler.cs @@ -0,0 +1,366 @@ +// 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 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); + +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, + 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); + return new(created, FoundryToolboxReconcileAction.Created); + } + + if (!existing.Default.Metadata.TryGetValue( + FoundryToolboxDeploymentDefinition.ManagedByMetadataKey, + out var managedBy) || + !string.Equals( + managedBy, + FoundryToolboxDeploymentDefinition.ManagedByMetadataValue, + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Toolbox '{definition.Name}' already exists but is not managed by Aspire. No changes were made."); + } + + if (existing.Default.Metadata.TryGetValue( + FoundryToolboxDeploymentDefinition.ConfigurationHashMetadataKey, + out var existingHash) && + string.Equals(existingHash, definition.ConfigurationHash, StringComparison.Ordinal)) + { + 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 administration.PromoteVersionAsync( + definition.Name, + reusableVersion.Version, + 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 administration.PromoteVersionAsync( + definition.Name, + updated, + cancellationToken).ConfigureAwait(false); + } + + return new(updated, FoundryToolboxReconcileAction.CreatedAndPromoted); + } +} + +internal sealed record FoundryToolboxReconcileResult( + string Version, + FoundryToolboxReconcileAction Action); + +internal enum FoundryToolboxReconcileAction +{ + Created, + Reused, + Promoted, + CreatedAndPromoted +} diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs index 1198cc0c8db..427ffd05a78 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs @@ -1,18 +1,14 @@ // 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.Globalization; +using System.ClientModel.Primitives; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure; using Aspire.Hosting.Pipelines; using Azure.AI.Projects; -using Azure.AI.Projects.Agents; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Polly; -using Polly.Retry; namespace Aspire.Hosting.Foundry; @@ -20,13 +16,12 @@ namespace Aspire.Hosting.Foundry; /// Represents a Microsoft Foundry Toolbox endpoint associated with a Foundry project. /// /// -/// Toolboxes are Foundry data-plane resources: there is no ARM/Bicep representation. Each call -/// to -/// creates a new immutable version of the toolbox. Aspire registers a deploy-time pipeline step -/// that resolves all tool definitions and calls the data plane API once the parent project's -/// endpoint is ready. +/// 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(ExposeProperties = true)] +[AspireExport] public sealed class FoundryToolboxResource : Resource, IResourceWithParent, IResourceWithConnectionString { internal const string DefaultApiVersion = "v1"; @@ -34,10 +29,6 @@ public sealed class FoundryToolboxResource : Resource, IResourceWithParent _tools = []; /// @@ -45,8 +36,11 @@ public sealed class FoundryToolboxResource : Resource, IResourceWithParent /// The Toolbox name. /// The parent Microsoft Foundry project resource. - /// The optional Toolbox version to reference. - public FoundryToolboxResource([ResourceName] string name, AzureCognitiveServicesProjectResource parent, string? version = null) + /// The optional existing Toolbox version to reference. + public FoundryToolboxResource( + [ResourceName] string name, + AzureCognitiveServicesProjectResource parent, + string? version = null) : base(name) { ArgumentNullException.ThrowIfNull(parent); @@ -54,61 +48,61 @@ public FoundryToolboxResource([ResourceName] string name, AzureCognitiveServices Parent = parent; Version = version; - // Register pipeline steps to create a new toolbox version on the Foundry data plane. - // Mirrors AzurePromptAgentResource: a publish-mode step that always runs and a run-mode - // step that kicks off the deployment before the application starts. Annotations.Add(new PipelineStepAnnotation(context => { var steps = new List(); if (context.PipelineContext.ExecutionContext.IsRunMode) { - var beforeStartDeployStep = new PipelineStep + steps.Add(new PipelineStep { Name = $"deploy-{Name}-before-start", - Description = $"Deploys toolbox {Name} before the application starts.", + Description = $"Reconciles Toolbox {Name} after the application starts.", Action = DeployBeforeStartAsync, RequiredBySteps = [BeforeStartStepName], Resource = this, - DependsOnSteps = [RunModeAzureProvisionStepName] - }; - steps.Add(beforeStartDeployStep); + DependsOnSteps = [AzureEnvironmentResource.PrepareResourcesStepName] + }); } - var toolboxDeployStep = new PipelineStep + steps.Add(new PipelineStep { Name = $"deploy-{Name}", - Description = $"Deploys toolbox {Name}.", - Action = async (stepCtx) => + Description = $"Reconciles Toolbox {Name}.", + Action = async stepContext => { - var version = await DeployAsync(Parent, stepCtx, logRetry: null, stepCtx.CancellationToken).ConfigureAwait(false); - stepCtx.ReportingStep.Log(LogLevel.Information, - new MarkdownString($"Successfully deployed **{Name}** as Foundry Toolbox (version {version.Version})")); - DeployedVersion.Set(version.Version); + var result = await ReconcileAsync( + stepContext, + message => stepContext.Logger.LogWarning("{Message}", message), + stepContext.CancellationToken).ConfigureAwait(false); + stepContext.ReportingStep.Log( + LogLevel.Information, + new MarkdownString( + $"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] - }; - steps.Add(toolboxDeployStep); + DependsOnSteps = + [ + WellKnownPipelineSteps.DeployPrereq, + AzureEnvironmentResource.ProvisionInfrastructureStepName + ] + }); return Task.FromResult>(steps); })); - // In publish mode the Foundry data plane calls back into MCP servers, so any MCP tool that - // points at a sibling compute resource (project, container, executable, app service, ACA) - // must have that resource deployed before the toolbox registers the URL. Walk the resolved - // pipeline graph and wire a tag-based dependency from this resource's deploy-compute step - // to every referenced compute resource's deploy-compute step. + // 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 => { var toolboxDeploySteps = context.GetSteps(this, WellKnownPipelineTags.DeployCompute); - foreach (var referenced in GetMcpReferencedResources(context.Model)) + foreach (var referencedResource in GetMcpReferencedResources(context.Model)) { - var referencedDeploySteps = context.GetSteps(referenced, WellKnownPipelineTags.DeployCompute); - toolboxDeploySteps.DependsOn(referencedDeploySteps); + toolboxDeploySteps.DependsOn( + context.GetSteps(referencedResource, WellKnownPipelineTags.DeployCompute)); } return Task.CompletedTask; @@ -121,11 +115,12 @@ public FoundryToolboxResource([ResourceName] string name, AzureCognitiveServices public AzureCognitiveServicesProjectResource Parent { get; } /// - /// Gets or sets the Toolbox version to reference. When unset, the default Toolbox version is used. + /// Gets or sets the Toolbox version used by consumers. /// /// - /// This is the user-pinned version used in the MCP endpoint URI. To read the version that was - /// produced by the most recent deployment, use . + /// 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; } @@ -135,29 +130,29 @@ public FoundryToolboxResource([ResourceName] string name, AzureCognitiveServices public string ApiVersion { get; set; } = DefaultApiVersion; /// - /// Gets or sets a description of the toolbox. Persisted as the toolbox version's description in Foundry. + /// Gets or sets the description persisted with each Toolbox version. /// public string Description { get; set; } = "Foundry Toolbox"; /// - /// Gets the metadata to associate with the toolbox version on creation. + /// Gets metadata persisted with each Toolbox version. /// - public IDictionary Metadata { get; init; } = new Dictionary() - { - { "DeployedBy", "Aspire Hosting Framework" }, - { "DeployedOn", DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture) } - }; + /// + /// 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); /// - /// The version produced by the most recent deployment. Populated after the deploy pipeline - /// step runs successfully; empty before that. + /// Gets the version selected by the most recent reconciliation. /// public StaticValueProvider DeployedVersion { get; } = new(); /// /// Gets the tool definitions modeled for this Toolbox. /// - public IReadOnlyList Tools => _tools; + internal IReadOnlyList Tools => _tools; /// /// Gets the Toolbox MCP endpoint URI expression. @@ -173,16 +168,35 @@ public FoundryToolboxResource([ResourceName] string name, AzureCognitiveServices internal void AddTool(FoundryToolboxToolDefinition tool) { + ArgumentNullException.ThrowIfNull(tool); + + 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 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() { - // Each connection property maps to an env var prefixed with the toolbox resource name - // (e.g. FIELD_TOOLS_URI). This lets multiple toolboxes coexist on the same consumer without - // colliding. Apps that only consume a single toolbox should prefer the canonical Foundry - // env vars emitted by the specialized WithReference overload (FOUNDRY_AGENT_TOOLBOX_*) so - // the same code path works both locally and in the Foundry hosted-agent runtime. yield return new("Name", ReferenceExpression.Create($"{Name}")); yield return new("ProjectEndpoint", ReferenceExpression.Create($"{Parent.Endpoint}")); yield return new("Uri", UriExpression); @@ -196,105 +210,59 @@ IEnumerable> IResourceWithConnectionSt } } - /// - /// Deploys the toolbox to the given Microsoft Foundry project, creating a new immutable toolbox - /// version via the data plane. - /// - private async Task DeployAsync( - AzureCognitiveServicesProjectResource project, + private async Task ReconcileAsync( PipelineStepContext context, - Action? logRetry, + Action logRetry, CancellationToken cancellationToken) { - ArgumentNullException.ThrowIfNull(project); - - var projectEndpoint = await project.Endpoint.GetValueAsync(cancellationToken).ConfigureAwait(false); - if (string.IsNullOrEmpty(projectEndpoint)) + 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($"Project '{project.Name}' does not have a valid endpoint."); + throw new InvalidOperationException( + $"Foundry project '{Parent.Name}' did not resolve to an absolute HTTPS endpoint."); } - var tokenCredentialProvider = context.Services.GetRequiredService(); - var credential = tokenCredentialProvider.TokenCredential; + endpoint = new Uri(endpoint.GetLeftPart(UriPartial.Path).TrimEnd('/')); - // Resolve each tool definition to its SDK shape. - var tools = new List(_tools.Count); - foreach (var tool in _tools) + var administration = context.Services.GetService(); + if (administration is null) { - var agentTool = await tool.ToProjectsAgentToolAsync(cancellationToken).ConfigureAwait(false); - tools.Add(agentTool); + 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); } - var projectClient = new AIProjectClient(new Uri(projectEndpoint), credential); - - var retryPipeline = new ResiliencePipelineBuilder() - .AddRetry(new RetryStrategyOptions - { - Delay = s_projectEndpointReadinessDelay, - MaxRetryAttempts = ProjectEndpointReadinessMaxRetryAttempts, - ShouldHandle = new PredicateBuilder() - .Handle(IsProjectEndpointNotReady), - OnRetry = retry => - { - var retryMessage = $"Foundry project endpoint for '{project.Name}' is not ready yet. Retrying toolbox deployment in {s_projectEndpointReadinessDelay.TotalSeconds:n0} seconds ({retry.AttemptNumber + 1}/{ProjectEndpointReadinessMaxRetryAttempts})."; - if (logRetry is not null) - { - logRetry?.Invoke(retryMessage); - } - else - { - context.ReportingStep.Log(LogLevel.Warning, retryMessage); - } - - return ValueTask.CompletedTask; - } - }) - .Build(); + var definition = await CreateDeploymentDefinitionAsync(cancellationToken).ConfigureAwait(false); + var result = await new FoundryToolboxReconciler(administration) + .ReconcileAsync(definition, cancellationToken).ConfigureAwait(false); + DeployedVersion.Set(result.Version); - return await retryPipeline.ExecuteAsync(async ct => - { - // Pass policies: null — this opts in to the V1Preview default policy set. The metadata - // dictionary copy guards against later mutation of Metadata on the resource between - // deploys (e.g. when a run-mode deploy redeploys after a publish-mode deploy). - var result = await projectClient.AgentAdministrationClient - .GetAgentToolboxes() - .CreateToolboxVersionAsync( - Name, - tools, - Description, - new Dictionary(Metadata), - policies: null, - cancellationToken: ct) - .ConfigureAwait(false); - - return result.Value; - }, cancellationToken).ConfigureAwait(false); + return result; } - private 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 Task DeployBeforeStartAsync(PipelineStepContext context) { - if (!context.ExecutionContext.IsRunMode) + if (context.ExecutionContext.IsRunMode) { - return Task.CompletedTask; + StartRunModeDeployment(context); } - StartRunModeDeployment(context); - return Task.CompletedTask; } private void StartRunModeDeployment(PipelineStepContext context) { - // Fire-and-forget so the application can start before the toolbox finishes deploying. - // The notification service surfaces the toolbox state in the dashboard while the deploy - // is in flight. Matches AzurePromptAgentResource. + // 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); + var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + context.CancellationToken, + lifetime.ApplicationStopping); _ = Task.Run(async () => { @@ -316,30 +284,43 @@ private async Task DeployForRunModeAsync( var notificationService = context.Services.GetRequiredService(); var model = context.Services.GetRequiredService(); var logger = context.Services.GetRequiredService().GetLogger(this); + try { - await notificationService.PublishUpdateAsync(this, s => s with + await notificationService.PublishUpdateAsync(this, snapshot => snapshot with { - State = new("Waiting for project", KnownResourceStateStyles.Info) + State = new("Waiting for dependencies", KnownResourceStateStyles.Info) }).ConfigureAwait(false); - await WaitForProjectAndToolsAsync(notificationService, model, cancellationToken).ConfigureAwait(false); + await WaitForProjectAndToolsAsync( + notificationService, + model, + cancellationToken).ConfigureAwait(false); - await notificationService.PublishUpdateAsync(this, s => s with + await notificationService.PublishUpdateAsync(this, snapshot => snapshot with { - State = new("Deploying toolbox", KnownResourceStateStyles.Info) + State = new("Reconciling Toolbox", KnownResourceStateStyles.Info) }).ConfigureAwait(false); - logger.LogInformation("Deploying toolbox '{ToolboxName}' to Foundry project '{ProjectName}'...", Name, Parent.Name); - - var version = await DeployAsync(Parent, context, message => logger.LogWarning("{Message}", message), cancellationToken).ConfigureAwait(false); - DeployedVersion.Set(version.Version); + var result = await ReconcileAsync( + context, + message => logger.LogWarning("{Message}", message), + cancellationToken).ConfigureAwait(false); - logger.LogInformation("Successfully deployed toolbox '{ToolboxName}' (version {Version})", Name, version.Version); + logger.LogInformation( + "Reconciled Toolbox '{ToolboxName}' at version {Version} with action {Action}.", + Name, + result.Version, + result.Action); - await notificationService.PublishUpdateAsync(this, s => s with + await notificationService.PublishUpdateAsync(this, snapshot => snapshot with { - State = new("Running", KnownResourceStateStyles.Success) + State = new("Running", KnownResourceStateStyles.Success), + Properties = + [ + new("Toolbox version", result.Version), + new("Reconciliation action", result.Action.ToString()) + ] }).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -347,16 +328,19 @@ await notificationService.PublishUpdateAsync(this, s => s with } catch (Exception ex) { - logger.LogError(ex, "Failed to deploy toolbox '{ToolboxName}'", Name); + logger.LogError(ex, "Failed to reconcile Toolbox '{ToolboxName}'.", Name); - await notificationService.PublishUpdateAsync(this, s => s with + await notificationService.PublishUpdateAsync(this, snapshot => snapshot with { - State = new("Failed to deploy", KnownResourceStateStyles.Error) + State = new("Failed to reconcile", KnownResourceStateStyles.Error) }).ConfigureAwait(false); } } - private async Task WaitForProjectAndToolsAsync(ResourceNotificationService notificationService, DistributedApplicationModel model, CancellationToken cancellationToken) + private async Task WaitForProjectAndToolsAsync( + ResourceNotificationService notificationService, + DistributedApplicationModel model, + CancellationToken cancellationToken) { if (Parent is IAzureResource { ProvisioningTaskCompletionSource: { } projectProvisioning }) { @@ -370,70 +354,50 @@ await notificationService.WaitForResourceAsync( cancellationToken).ConfigureAwait(false); } - // The only sub-resources we need to await are the Azure AI Search project connections - // backing AzureAISearch toolbox tools. WebSearch needs no provisioning, and MCP tools - // expose only a URL expression - those are handled separately below. - var toolConnectionProvisioningTasks = _tools - .Select(tool => tool switch - { - FoundryToolboxAzureAISearchToolDefinition { Connection: IAzureResource searchConnection } => searchConnection.ProvisioningTaskCompletionSource?.Task, - _ => null - }) - .OfType(); - - // Wait for any locally-hosted compute resources targeted by MCP tools before we try to - // register their URLs with Foundry. Restrict to IComputeResource + IResourceWithWaitSupport - // so we don't block forever on Azure-only or model-only resources that never publish a - // Running state via the notification service. - var mcpRunModeWaits = GetMcpReferencedResources(model) - .Where(r => r is IComputeResource and IResourceWithWaitSupport) - .Select(r => notificationService.WaitForResourceAsync(r.Name, KnownResourceStates.Running, cancellationToken)); - - await Task.WhenAll(toolConnectionProvisioningTasks - .Select(task => task.WaitAsync(cancellationToken)) - .Concat(mcpRunModeWaits)).ConfigureAwait(false); + 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 => notificationService.WaitForResourceAsync( + resource.Name, + KnownResourceStates.Running, + cancellationToken)); + + await Task.WhenAll(connectionProvisioningTasks.Concat(mcpResourceWaits)).ConfigureAwait(false); } - // Returns the distinct set of resources referenced by the MCP tools' endpoint expressions that - // also exist in the current application model. Restricting to model membership avoids waiting on - // externally constructed references (e.g. a synthetic EndpointReference produced for test setup) - // and avoids self-dependencies that would deadlock the pipeline. private IEnumerable GetMcpReferencedResources(DistributedApplicationModel model) { - var modelResources = new HashSet(model.Resources); - var seen = new HashSet(); + // 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), + StringComparer.Ordinal); + var seenResourceNames = new HashSet(StringComparer.Ordinal); foreach (var tool in _tools.OfType()) { - foreach (var referenced in WalkValueReferences(tool.EndpointExpression).OfType()) + foreach (var referencedResource in WalkValueReferences(tool.EndpointExpression).OfType()) { - if (ReferenceEquals(referenced, this)) + if (!ReferenceEquals(referencedResource, this) && + modelResourceNames.Contains(referencedResource.Name) && + seenResourceNames.Add(referencedResource.Name)) { - continue; - } - - if (!modelResources.Contains(referenced)) - { - continue; - } - - if (seen.Add(referenced)) - { - yield return referenced; + yield return referencedResource; } } } } - // Depth-first walk of every object reachable via IValueWithReferences. Used to surface the - // concrete IResource instances that back an arbitrary ReferenceExpression so we can wire - // pipeline dependencies and run-mode waits regardless of how the user composed the expression - // (raw EndpointReference, EndpointReferenceExpression, nested ReferenceExpression, etc.). private static IEnumerable WalkValueReferences(object root) { var stack = new Stack(); var visited = new HashSet(ReferenceEqualityComparer.Instance); - stack.Push(root); while (stack.Count > 0) @@ -446,9 +410,9 @@ private static IEnumerable WalkValueReferences(object root) yield return current; - if (current is IValueWithReferences withRefs) + if (current is IValueWithReferences valueWithReferences) { - foreach (var reference in withRefs.References) + foreach (var reference in valueWithReferences.References) { if (reference is not null) { @@ -459,3 +423,26 @@ private static IEnumerable WalkValueReferences(object root) } } } + +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 index 237b0b14f4a..52f90d4eb34 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs @@ -12,7 +12,7 @@ namespace Aspire.Hosting.Foundry; /// /// Base type for Microsoft Foundry Toolbox tool definitions. /// -public abstract class FoundryToolboxToolDefinition +internal abstract class FoundryToolboxToolDefinition { private protected FoundryToolboxToolDefinition(string name) { @@ -31,20 +31,26 @@ private protected FoundryToolboxToolDefinition(string name) /// Foundry data plane when creating a new toolbox version. /// /// Cancellation token. - internal abstract ValueTask ToProjectsAgentToolAsync(CancellationToken cancellationToken); + internal async ValueTask ToProjectsAgentToolAsync(CancellationToken cancellationToken) + { + var resolved = await ResolveAsync(cancellationToken).ConfigureAwait(false); + return resolved.Tool; + } + + internal abstract ValueTask ResolveAsync(CancellationToken cancellationToken); } /// /// Describes a web search tool in a Microsoft Foundry Toolbox. /// -public sealed class FoundryToolboxWebSearchToolDefinition : FoundryToolboxToolDefinition +internal sealed class FoundryToolboxWebSearchToolDefinition : FoundryToolboxToolDefinition { internal FoundryToolboxWebSearchToolDefinition(string name) : base(name) { } - internal override ValueTask ToProjectsAgentToolAsync(CancellationToken cancellationToken) + 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. @@ -76,14 +82,20 @@ internal override ValueTask ToProjectsAgentToolAsync(Cancella // See https://platform.openai.com/docs/api-reference/responses/create#responses-create-tools. var json = BinaryData.FromString("""{"type":"web_search"}"""); var agentTool = ModelReaderWriter.Read(json, ModelReaderWriterOptions.Json, AzureAIProjectsAgentsContext.Default); - return new ValueTask(agentTool!); + var canonicalConfiguration = JsonSerializer.Serialize(new + { + type = "web_search", + name = Name + }); + return new ValueTask( + new ResolvedFoundryToolboxTool(Name, agentTool!, canonicalConfiguration)); } } /// /// Describes an MCP tool in a Microsoft Foundry Toolbox. /// -public sealed class FoundryToolboxMcpToolDefinition : FoundryToolboxToolDefinition +internal sealed class FoundryToolboxMcpToolDefinition : FoundryToolboxToolDefinition { internal FoundryToolboxMcpToolDefinition(string name, ReferenceExpression endpointExpression) : base(name) @@ -98,33 +110,7 @@ internal FoundryToolboxMcpToolDefinition(string name, ReferenceExpression endpoi /// public ReferenceExpression EndpointExpression { get; } - /// - /// Gets or sets an optional expression that resolves to the bearer token sent to the MCP - /// server on every request. Use this for MCP servers that require authorization. - /// - /// - /// When set, the resolved value is forwarded to the Foundry data plane and the Foundry agent - /// runtime sends it as the Authorization header (with the standard Bearer scheme) - /// when invoking the MCP server. The expression is resolved at toolbox deploy time so it can - /// safely reference instances or other - /// deploy-time values. - /// - public ReferenceExpression? AuthorizationTokenExpression { get; set; } - - /// - /// Gets the set of HTTP headers the Foundry agent runtime sends to the MCP server on every - /// request. Header names are matched case-insensitively, mirroring HTTP semantics. - /// - /// - /// Each value is a that is resolved at toolbox deploy time - /// so headers can safely reference instances - /// or other deploy-time values. Header entries whose value resolves to - /// or an empty string are silently omitted. - /// - public IDictionary Headers { get; } = - new Dictionary(StringComparer.OrdinalIgnoreCase); - - internal override async ValueTask ToProjectsAgentToolAsync(CancellationToken cancellationToken) + internal override async ValueTask ResolveAsync(CancellationToken cancellationToken) { var endpoint = await EndpointExpression.GetValueAsync(cancellationToken).ConfigureAwait(false); if (string.IsNullOrEmpty(endpoint)) @@ -133,46 +119,11 @@ internal override async ValueTask ToProjectsAgentToolAsync(Ca $"MCP tool '{Name}' does not have a resolvable endpoint URI."); } - string? authorizationToken = null; - if (AuthorizationTokenExpression is not null) - { - authorizationToken = await AuthorizationTokenExpression.GetValueAsync(cancellationToken).ConfigureAwait(false); - if (string.IsNullOrEmpty(authorizationToken)) - { - throw new InvalidOperationException( - $"MCP tool '{Name}' was configured with an authorization token but the token expression resolved to an empty value."); - } - } - - // Resolve every configured header to its concrete value, dropping any that produce a - // null/empty string (Headers is intended for explicit values; empty values are a no-op - // rather than a configuration error). - IDictionary? headers = null; - if (Headers.Count > 0) + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri) || + endpointUri.Scheme != Uri.UriSchemeHttps) { - headers = new Dictionary(Headers.Count, StringComparer.OrdinalIgnoreCase); - foreach (var header in Headers) - { - if (string.IsNullOrEmpty(header.Key)) - { - throw new InvalidOperationException( - $"MCP tool '{Name}' has a header with an empty name."); - } - - var value = await header.Value.GetValueAsync(cancellationToken).ConfigureAwait(false); - if (!string.IsNullOrEmpty(value)) - { - headers[header.Key] = value; - } - } - - // Drop the dictionary if every entry resolved to an empty value: the SDK distinguishes - // null from an empty collection in some wire forms, and we prefer "no headers configured" - // semantics when nothing was actually supplied. - if (headers.Count == 0) - { - headers = null; - } + throw new InvalidOperationException( + $"MCP tool '{Name}' must resolve to an absolute HTTPS endpoint."); } // Build the OpenAI Responses "mcp" tool wire JSON by hand and read it back as a @@ -187,9 +138,7 @@ internal override async ValueTask ToProjectsAgentToolAsync(Ca // { // "type": "mcp", // "server_label": "", - // "server_url": "", // optional, but required for hosted MCP - // "authorization":"", // optional bearer token (sans "Bearer ") - // "headers": { "": "", ... } // optional extra headers + // "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. @@ -199,35 +148,24 @@ internal override async ValueTask ToProjectsAgentToolAsync(Ca writer.WriteStartObject(); writer.WriteString("type", "mcp"); writer.WriteString("server_label", Name); - writer.WriteString("server_url", new Uri(endpoint).AbsoluteUri); - - if (!string.IsNullOrEmpty(authorizationToken)) - { - writer.WriteString("authorization", authorizationToken); - } - - if (headers is { Count: > 0 }) - { - writer.WriteStartObject("headers"); - foreach (var header in headers) - { - writer.WriteString(header.Key, header.Value); - } - writer.WriteEndObject(); - } - + writer.WriteString("server_url", endpointUri.AbsoluteUri); writer.WriteEndObject(); } var json = BinaryData.FromBytes(stream.ToArray()); - return ModelReaderWriter.Read(json, ModelReaderWriterOptions.Json, AzureAIProjectsAgentsContext.Default)!; + var tool = ModelReaderWriter.Read( + json, + ModelReaderWriterOptions.Json, + AzureAIProjectsAgentsContext.Default)!; + + return new ResolvedFoundryToolboxTool(Name, tool, json.ToString()); } } /// /// Describes an Azure AI Search tool in a Microsoft Foundry Toolbox. /// -public sealed class FoundryToolboxAzureAISearchToolDefinition : FoundryToolboxToolDefinition +internal sealed class FoundryToolboxAzureAISearchToolDefinition : FoundryToolboxToolDefinition { internal FoundryToolboxAzureAISearchToolDefinition( string name, @@ -259,7 +197,7 @@ internal FoundryToolboxAzureAISearchToolDefinition( /// public string? IndexName { get; } - internal override async ValueTask ToProjectsAgentToolAsync(CancellationToken cancellationToken) + 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. @@ -278,6 +216,15 @@ internal override async ValueTask ToProjectsAgentToolAsync(Ca IndexName = IndexName }; var options = new AzureAISearchToolOptions([index]); - return new AzureAISearchTool(options); + var tool = new AzureAISearchTool(options); + var canonicalConfiguration = JsonSerializer.Serialize(new + { + type = "azure_ai_search", + name = Name, + projectConnectionId = connectionId, + indexName = IndexName + }); + + return new ResolvedFoundryToolboxTool(Name, tool, canonicalConfiguration); } } diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs index 7587f0a7ecc..0faeedfeb92 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs @@ -29,6 +29,133 @@ public async Task DeployFoundryHostedAgentToAzure() await DeployFoundryHostedAgentToAzureCore(cancellationToken); } + [Fact] + public async Task DeployFoundryToolboxToAzure() + { + using var cts = new CancellationTokenSource(s_testTimeout); + 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"); + + foundryProject.AddToolbox("field-tools") + .WithDescription("Tools for field technicians.") + .WithWebSearchTool(); + + 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=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}"); + 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.WaitUntilTextAsync("action Created", timeout: TimeSpan.FromMinutes(35)); + await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(2)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + + await auto.TypeAsync("aspire deploy --clear-cache"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync("action Reused", timeout: TimeSpan.FromMinutes(15)); + await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(2)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + + 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 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..2b12864b287 --- /dev/null +++ b/tests/Aspire.Hosting.Foundry.Tests/FoundryToolboxReconcilerTests.cs @@ -0,0 +1,248 @@ +// 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; + +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.Created, 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_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 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_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_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); + } + + 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 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; init; } + + public string VersionToCreate { get; init; } = "1"; + + public List CreatedDefinitions { get; } = []; + + public List<(string Name, string Version)> Promotions { get; } = []; + + public Task GetAsync(string name, CancellationToken cancellationToken) => + Task.FromResult(Existing); + + public Task CreateVersionAsync( + FoundryToolboxDeploymentDefinition definition, + CancellationToken cancellationToken) + { + CreatedDefinitions.Add(definition); + return Task.FromResult(VersionToCreate); + } + + public Task PromoteVersionAsync( + string name, + string version, + CancellationToken cancellationToken) + { + Promotions.Add((name, version)); + return Task.CompletedTask; + } + } +} diff --git a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs index 90b32f31af2..8edb4766c6a 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs @@ -25,10 +25,10 @@ public void AddToolbox_CreatesProjectChildResource() var project = builder.AddFoundry("account") .AddProject("my-project"); - var toolbox = project.AddToolbox("field-tools", t => t.Version = "v1"); + var toolbox = project.AddToolbox("field-tools", t => t.Version = "7"); Assert.Equal("field-tools", toolbox.Resource.Name); - Assert.Equal("v1", toolbox.Resource.Version); + Assert.Equal("7", toolbox.Resource.Version); Assert.Same(project.Resource, toolbox.Resource.Parent); } @@ -41,6 +41,7 @@ public void WithToolMethods_AddToolDefinitions() 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"); @@ -74,7 +75,7 @@ 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 = "v1"); + var toolbox = project.AddToolbox("field-tools", t => t.Version = "7"); var pyapp = builder.AddPythonApp("app", "./app.py", "main:app") .WithReference(toolbox); @@ -91,7 +92,7 @@ public async Task WithReference_InjectsToolboxConnectionProperties() && 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/v1/mcp?api-version=v1"); + && 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"); @@ -109,7 +110,7 @@ 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 = "v1"); + var toolbox = project.AddToolbox("field-tools", t => t.Version = "7"); var agent = builder.AddPythonApp("agent", "./app.py", "main:app") .WithReference(toolbox) @@ -133,7 +134,7 @@ public async Task AsHostedAgent_ResolvesToolboxConnectionString() NullLogger.Instance, cts.Token); - Assert.Equal("https://project.example.com/toolboxes/field-tools/versions/v1/mcp?api-version=v1", envVars["ConnectionStrings__field-tools"]); + Assert.Equal("https://project.example.com/toolboxes/field-tools/versions/7/mcp?api-version=v1", envVars["ConnectionStrings__field-tools"]); } [Fact] @@ -142,7 +143,7 @@ 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", t => t.Version = "v1"); + var toolbox = project.AddToolbox("field-tools"); using var app = builder.Build(); @@ -172,7 +173,7 @@ public async Task AddToolbox_RegistersRunModeBeforeStartStep() using var builder = TestDistributedApplicationBuilder.Create(); var project = builder.AddFoundry("account") .AddProject("my-project"); - var toolbox = project.AddToolbox("field-tools", t => t.Version = "v1"); + var toolbox = project.AddToolbox("field-tools"); using var app = builder.Build(); @@ -190,7 +191,7 @@ public async Task AddToolbox_RegistersRunModeBeforeStartStep() var beforeStart = Assert.Single(steps, s => s.Name == "deploy-field-tools-before-start"); Assert.Contains("before-start", beforeStart.RequiredBySteps); - Assert.Contains("run-mode-azure-provision", beforeStart.DependsOnSteps); + Assert.Contains(AzureEnvironmentResource.PrepareResourcesStepName, beforeStart.DependsOnSteps); Assert.Same(toolbox.Resource, beforeStart.Resource); var deploy = Assert.Single(steps, s => s.Name == "deploy-field-tools"); @@ -266,67 +267,31 @@ await Assert.ThrowsAsync( } [Fact] - public void WithMcpTool_ConfigureCallback_SetsAuthorizationTokenAndHeaders() + public async Task McpToolDefinition_ThrowsWhenEndpointIsNotHttps() { using var builder = TestDistributedApplicationBuilder.Create(); - var project = builder.AddFoundry("account") - .AddProject("my-project"); - var token = builder.AddParameter("mcp-token", () => "secret-value"); - var traceparent = builder.AddParameter("traceparent", () => "00-aaaa-bbbb-01"); - - var toolbox = project.AddToolbox("field-tools") - .WithMcpTool("inventory", "https://inventory.example.com/mcp", t => - { - t.AuthorizationTokenExpression = ReferenceExpression.Create($"{token.Resource}"); - t.Headers["X-Trace-Parent"] = ReferenceExpression.Create($"{traceparent.Resource}"); - // Header name matching is case-insensitive: writing through one casing and then - // reading through another must resolve to the same entry. - t.Headers["x-trace-parent"] = ReferenceExpression.Create($"{traceparent.Resource}-2"); - }); - - var def = Assert.IsType(toolbox.Resource.Tools[0]); - - Assert.NotNull(def.AuthorizationTokenExpression); - Assert.Single(def.Headers); - Assert.True(def.Headers.ContainsKey("X-TRACE-PARENT")); - } - - [Fact] - public async Task McpToolDefinition_AuthorizationToken_EmptyValue_Throws() - { - using var builder = TestDistributedApplicationBuilder.Create(); - var empty = builder.AddParameter("empty-token", () => string.Empty); var def = new FoundryToolboxMcpToolDefinition( "inventory", - ReferenceExpression.Create($"https://inventory.example.com/mcp")) - { - AuthorizationTokenExpression = ReferenceExpression.Create($"{empty.Resource}") - }; + ReferenceExpression.Create($"http://inventory.example.com/mcp")); var ex = await Assert.ThrowsAsync( async () => await def.ToProjectsAgentToolAsync(CancellationToken.None)); - Assert.Contains("authorization token", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("absolute HTTPS endpoint", ex.Message, StringComparison.Ordinal); } [Fact] - public async Task McpToolDefinition_Headers_EmptyValueOmitted_DoesNotThrow() + public void WithMcpTool_ThrowsImmediatelyWhenLiteralEndpointIsNotHttps() { using var builder = TestDistributedApplicationBuilder.Create(); - var present = builder.AddParameter("present", () => "v1"); - var empty = builder.AddParameter("empty-header", () => string.Empty); - - var def = new FoundryToolboxMcpToolDefinition( - "inventory", - ReferenceExpression.Create($"https://inventory.example.com/mcp")); - def.Headers["X-Present"] = ReferenceExpression.Create($"{present.Resource}"); - // Empty header values are silently dropped (vs. throwing) so optional headers backed by - // parameters can be safely omitted in environments where the upstream value isn't set. - def.Headers["X-Optional"] = ReferenceExpression.Create($"{empty.Resource}"); + var project = builder.AddFoundry("account") + .AddProject("my-project"); - var projectTool = await def.ToProjectsAgentToolAsync(CancellationToken.None); + var exception = Assert.Throws( + () => project.AddToolbox("field-tools") + .WithMcpTool("inventory", "http://inventory.example.com/mcp")); - Assert.NotNull(projectTool); + Assert.Equal("endpoint", exception.ParamName); } [Fact] @@ -343,6 +308,9 @@ public async Task AddToolbox_McpTool_PublishConfigurationAnnotation_WiresDepende 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 @@ -361,7 +329,7 @@ public async Task AddToolbox_McpTool_PublishConfigurationAnnotation_WiresDepende { Name = "deploy-mcp", Action = _ => Task.CompletedTask, - Resource = mcp.Resource, + Resource = replacement, Tags = { WellKnownPipelineTags.DeployCompute }, }; diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts index f296af6e2f3..8c8030e4fc5 100644 --- a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts @@ -82,7 +82,8 @@ await _promptAgent.withTool(azFunc); await _promptAgent.withTool(funcTool); // Foundry Toolbox -const toolbox = await project.addToolbox('field-tools', { version: 'v1' }); +const toolbox = await project.addToolbox('field-tools'); +await toolbox.withDescription('Tools for field technicians.'); await toolbox.withWebSearchTool(); await toolbox.withMcpTool('inventory', api.getEndpoint('http')); await toolbox.withAISearchTool('knowledge-base', search); From f4393da8a2e12c3e5bc047c4089f8c724a50bdd6 Mon Sep 17 00:00:00 2001 From: tommasodotnet Date: Tue, 1 Sep 2026 18:50:15 +0200 Subject: [PATCH 09/30] Address Foundry Toolbox review feedback Make search connections deterministic, serialize tool names, close the initial-version promotion race, reject local MCP endpoints, document Toolbox usage, and extend Azure Search deployment coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Project/ConnectionBuilderExtensions.cs | 9 ++- src/Aspire.Hosting.Foundry/README.md | 62 +++++++++++++++++++ .../FoundryToolboxBuilderExtensions.cs | 26 +++++++- .../Toolbox/FoundryToolboxReconciler.cs | 10 ++- .../Toolbox/FoundryToolboxToolDefinition.cs | 49 ++++++++++----- .../FoundryHostedAgentDeploymentTests.cs | 6 +- .../FoundryToolboxReconcilerTests.cs | 4 +- .../ToolboxTests.cs | 51 ++++++++++++++- .../TypeScript/apphost.mts | 2 +- 9 files changed, 193 insertions(+), 26 deletions(-) diff --git a/src/Aspire.Hosting.Foundry/Project/ConnectionBuilderExtensions.cs b/src/Aspire.Hosting.Foundry/Project/ConnectionBuilderExtensions.cs index b32068bf157..eff43b33952 100644 --- a/src/Aspire.Hosting.Foundry/Project/ConnectionBuilderExtensions.cs +++ b/src/Aspire.Hosting.Foundry/Project/ConnectionBuilderExtensions.cs @@ -203,11 +203,18 @@ 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) => + return builder.AddConnection(name, (infra) => { var searchService = (SearchService)search.AddAsExistingResource(infra); return new AadAuthTypeConnectionProperties() diff --git a/src/Aspire.Hosting.Foundry/README.md b/src/Aspire.Hosting.Foundry/README.md index c23a1f9a18d..67caac5fa52 100644 --- a/src/Aspire.Hosting.Foundry/README.md +++ b/src/Aspire.Hosting.Foundry/README.md @@ -129,6 +129,68 @@ 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() + .WithAISearchTool("knowledge-base", search, "docs") + .WithMcpTool("inventory", "https://inventory.example.com/mcp"); + +builder.AddProject("service") + .WithReference(toolbox); +``` + +**TypeScript** + +```typescript +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(); +await toolbox.withAISearchTool("knowledge-base", search, "docs"); +await toolbox.withMcpTool("inventory", "https://inventory.example.com/mcp"); + +const service = await builder.addNodeApp("service", "../service", "server.js"); +await service.withReference(toolbox); +``` + +MCP endpoints must be publicly reachable over HTTPS because the Foundry data plane invokes them. +For local development, use a public development tunnel instead of a localhost endpoint. Inline +credentials and headers are not supported; configure authenticated MCP servers through Foundry +project connections. + +The default consumer endpoint always serves the promoted Toolbox version. Set +`FoundryToolboxResource.Version` only when a consumer must target a specific immutable version. + +### 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 | + ## 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. diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs index 2550ccfa3bc..26ddc113d35 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs @@ -4,6 +4,8 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure; using Aspire.Hosting.Foundry; +using System.IO.Hashing; +using System.Text; namespace Aspire.Hosting; @@ -135,6 +137,12 @@ public static IResourceBuilder WithMcpTool( /// The resource builder for the Toolbox. /// The tool name. /// The MCP endpoint. + /// + /// During local development, the endpoint must resolve to a publicly 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.")] @@ -200,7 +208,12 @@ public static IResourceBuilder WithAISearchTool( ArgumentNullException.ThrowIfNull(search); var projectBuilder = builder.ApplicationBuilder.CreateResourceBuilder(builder.Resource.Parent); - var connection = projectBuilder.AddConnection(search); + 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, @@ -210,6 +223,17 @@ public static IResourceBuilder WithAISearchTool( 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 IResourceBuilder WithMcpTool( this IResourceBuilder builder, string name, diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReconciler.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReconciler.cs index d886121005f..69808dd814e 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReconciler.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReconciler.cs @@ -296,7 +296,14 @@ public async Task ReconcileAsync( if (existing is null) { var created = await administration.CreateVersionAsync(definition, cancellationToken).ConfigureAwait(false); - return new(created, FoundryToolboxReconcileAction.Created); + // Another deployment can create the same Toolbox between the read and create calls. + // Explicitly promote our immutable version so the unversioned consumer endpoint serves + // the configuration this deployment just reconciled. + await administration.PromoteVersionAsync( + definition.Name, + created, + cancellationToken).ConfigureAwait(false); + return new(created, FoundryToolboxReconcileAction.CreatedAndPromoted); } if (!existing.Default.Metadata.TryGetValue( @@ -359,7 +366,6 @@ internal sealed record FoundryToolboxReconcileResult( internal enum FoundryToolboxReconcileAction { - Created, Reused, Promoted, CreatedAndPromoted diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs index 52f90d4eb34..0ecb22fd3eb 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs @@ -78,17 +78,18 @@ internal override ValueTask ResolveAsync(Cancellatio // comes into play. The Read side is fine because AzureAIProjectsAgentsContext is resolved // from the same ALC as the SCM it talks to. // - // The OpenAI Responses "web_search" tool has a fixed wire shape: {"type":"web_search"}. - // See https://platform.openai.com/docs/api-reference/responses/create#responses-create-tools. - var json = BinaryData.FromString("""{"type":"web_search"}"""); - var agentTool = ModelReaderWriter.Read(json, ModelReaderWriterOptions.Json, AzureAIProjectsAgentsContext.Default); - var canonicalConfiguration = JsonSerializer.Serialize(new + // 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. + var json = BinaryData.FromObjectAsJson(new { type = "web_search", name = Name }); + var agentTool = ModelReaderWriter.Read(json, ModelReaderWriterOptions.Json, AzureAIProjectsAgentsContext.Default); return new ValueTask( - new ResolvedFoundryToolboxTool(Name, agentTool!, canonicalConfiguration)); + new ResolvedFoundryToolboxTool(Name, agentTool!, json.ToString())); } } @@ -120,10 +121,11 @@ internal override async ValueTask ResolveAsync(Cance } if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri) || - endpointUri.Scheme != Uri.UriSchemeHttps) + endpointUri.Scheme != Uri.UriSchemeHttps || + endpointUri.IsLoopback) { throw new InvalidOperationException( - $"MCP tool '{Name}' must resolve to an absolute HTTPS endpoint."); + $"MCP tool '{Name}' must resolve to a publicly reachable absolute HTTPS endpoint."); } // Build the OpenAI Responses "mcp" tool wire JSON by hand and read it back as a @@ -216,15 +218,30 @@ internal override async ValueTask ResolveAsync(Cance IndexName = IndexName }; var options = new AzureAISearchToolOptions([index]); - var tool = new AzureAISearchTool(options); - var canonicalConfiguration = JsonSerializer.Serialize(new + 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)) { - type = "azure_ai_search", - name = Name, - projectConnectionId = connectionId, - indexName = IndexName - }); + writer.WriteStartObject(); + foreach (var property in unnamedDocument.RootElement.EnumerateObject()) + { + property.WriteTo(writer); + } + writer.WriteString("name", Name); + writer.WriteEndObject(); + } + + var json = BinaryData.FromBytes(stream.ToArray()); + var tool = ModelReaderWriter.Read( + json, + ModelReaderWriterOptions.Json, + AzureAIProjectsAgentsContext.Default)!; - return new ResolvedFoundryToolboxTool(Name, tool, canonicalConfiguration); + return new ResolvedFoundryToolboxTool(Name, tool, json.ToString()); } } diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs index 0faeedfeb92..b08d2f785fc 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs @@ -90,10 +90,12 @@ public async Task DeployFoundryToolboxToAzure() """ 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(); + .WithWebSearchTool() + .WithAISearchTool("knowledge-base", search); builder.Build().Run(); """); @@ -111,7 +113,7 @@ await auto.TypeAsync( // immutable version rather than producing another one. await auto.TypeAsync("aspire deploy --clear-cache"); await auto.EnterAsync(); - await auto.WaitUntilTextAsync("action Created", timeout: TimeSpan.FromMinutes(35)); + await auto.WaitUntilTextAsync("action CreatedAndPromoted", timeout: TimeSpan.FromMinutes(35)); await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(2)); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); diff --git a/tests/Aspire.Hosting.Foundry.Tests/FoundryToolboxReconcilerTests.cs b/tests/Aspire.Hosting.Foundry.Tests/FoundryToolboxReconcilerTests.cs index 2b12864b287..dcb6deb1d77 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/FoundryToolboxReconcilerTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/FoundryToolboxReconcilerTests.cs @@ -17,10 +17,10 @@ public async Task ReconcileAsync_CreatesFirstVersion() var result = await new FoundryToolboxReconciler(administration) .ReconcileAsync(definition, CancellationToken.None); - Assert.Equal(FoundryToolboxReconcileAction.Created, result.Action); + Assert.Equal(FoundryToolboxReconcileAction.CreatedAndPromoted, result.Action); Assert.Equal("1", result.Version); Assert.Same(definition, Assert.Single(administration.CreatedDefinitions)); - Assert.Empty(administration.Promotions); + Assert.Equal(("field-tools", "1"), Assert.Single(administration.Promotions)); } [Fact] diff --git a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs index 8edb4766c6a..72db2b3e88f 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs @@ -5,6 +5,7 @@ #pragma warning disable ASPIREPIPELINES001 // Pipelines APIs are experimental. #pragma warning disable ASPIREAZURE001 // AzureEnvironmentResource is experimental. +using System.ClientModel.Primitives; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure; using Aspire.Hosting.Pipelines; @@ -206,6 +207,11 @@ public async Task WebSearchToolDefinition_ConvertsToProjectsAgentTool() var projectTool = await tool.ToProjectsAgentToolAsync(CancellationToken.None); Assert.NotNull(projectTool); + var json = ModelReaderWriter.Write( + projectTool, + ModelReaderWriterOptions.Json, + AzureAIProjectsAgentsContext.Default); + Assert.Equal("""{"type":"web_search","name":"web-search"}""", json.ToString()); } [Fact] @@ -230,6 +236,11 @@ public async Task AzureAISearchToolDefinition_ConvertsToAzureAISearchTool() 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\"", json.ToString(), StringComparison.Ordinal); } [Fact] @@ -277,7 +288,20 @@ public async Task McpToolDefinition_ThrowsWhenEndpointIsNotHttps() var ex = await Assert.ThrowsAsync( async () => await def.ToProjectsAgentToolAsync(CancellationToken.None)); - Assert.Contains("absolute HTTPS endpoint", ex.Message, StringComparison.Ordinal); + Assert.Contains("publicly 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.ToProjectsAgentToolAsync(CancellationToken.None)); + + Assert.Contains("publicly reachable", exception.Message, StringComparison.Ordinal); } [Fact] @@ -294,6 +318,31 @@ public void WithMcpTool_ThrowsImmediatelyWhenLiteralEndpointIsNotHttps() 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 async Task AddToolbox_McpTool_PublishConfigurationAnnotation_WiresDependencyOnReferencedCompute() { diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts index 8c8030e4fc5..998ec2629a9 100644 --- a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts @@ -85,7 +85,7 @@ await _promptAgent.withTool(funcTool); const toolbox = await project.addToolbox('field-tools'); await toolbox.withDescription('Tools for field technicians.'); await toolbox.withWebSearchTool(); -await toolbox.withMcpTool('inventory', api.getEndpoint('http')); +await toolbox.withMcpTool('inventory', 'https://inventory.example.com/mcp'); await toolbox.withAISearchTool('knowledge-base', search); const builderProjectFoundry = await builder.addFoundry('builder-project-foundry'); From c7f7b402f8d84bc344b17e11ff9aee0a2ae59b47 Mon Sep 17 00:00:00 2001 From: tommasodotnet Date: Tue, 1 Sep 2026 19:06:43 +0200 Subject: [PATCH 10/30] Complete Toolbox review coverage Require Azure AI Search index names and role assignments, align resource-name comparison, cover MCP serialization and polyglot options, and complete Toolbox documentation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../FoundryAgentBasic.AppHost/AppHost.cs | 4 ++-- src/Aspire.Hosting.Foundry/README.md | 1 + .../FoundryToolboxBuilderExtensions.cs | 14 +++++++---- .../Toolbox/FoundryToolboxResource.cs | 4 ++-- .../Toolbox/FoundryToolboxToolDefinition.cs | 6 ++--- .../FoundryHostedAgentDeploymentTests.cs | 2 +- .../ToolboxTests.cs | 23 ++++++++++++++++++- .../TypeScript/apphost.mts | 4 ++-- 8 files changed, 43 insertions(+), 15 deletions(-) diff --git a/playground/FoundryAgentBasic/FoundryAgentBasic.AppHost/AppHost.cs b/playground/FoundryAgentBasic/FoundryAgentBasic.AppHost/AppHost.cs index e6cb64fb9e2..17843ec1636 100644 --- a/playground/FoundryAgentBasic/FoundryAgentBasic.AppHost/AppHost.cs +++ b/playground/FoundryAgentBasic/FoundryAgentBasic.AppHost/AppHost.cs @@ -7,8 +7,8 @@ project.AddModelDeployment("chat", FoundryModel.OpenAI.Gpt41Mini); -// Add a Foundry Toolbox with a single WebSearch tool. The toolbox is created on the Foundry data -// plane at deploy time via AgentToolboxes.CreateToolboxVersionAsync. +// 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(); diff --git a/src/Aspire.Hosting.Foundry/README.md b/src/Aspire.Hosting.Foundry/README.md index 67caac5fa52..8f1b8a37fb2 100644 --- a/src/Aspire.Hosting.Foundry/README.md +++ b/src/Aspire.Hosting.Foundry/README.md @@ -283,6 +283,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 index 26ddc113d35..88d4783b9e9 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs @@ -1,11 +1,12 @@ // 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; -using System.IO.Hashing; -using System.Text; +using Azure.Provisioning.Search; namespace Aspire.Hosting; @@ -193,7 +194,7 @@ internal static IResourceBuilder WithMcpToolForPolyglot( /// The resource builder for the Toolbox. /// The tool name. /// The Azure AI Search resource backing the tool. - /// The optional search index name. + /// The search index name. /// A reference to the for chaining. /// The resource builder. [AspireExport] @@ -201,13 +202,18 @@ public static IResourceBuilder WithAISearchTool( this IResourceBuilder builder, string name, IResourceBuilder search, - string? indexName = null) + string indexName) { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(name); ArgumentNullException.ThrowIfNull(search); + ArgumentException.ThrowIfNullOrWhiteSpace(indexName); var projectBuilder = builder.ApplicationBuilder.CreateResourceBuilder(builder.Resource.Parent); + projectBuilder.WithRoleAssignments( + search, + SearchBuiltInRole.SearchIndexDataReader, + SearchBuiltInRole.SearchServiceContributor); var connectionName = CreateSearchConnectionName( builder.Resource.Parent.Name, builder.Resource.Name, diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs index 427ffd05a78..4b69ade463d 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs @@ -377,8 +377,8 @@ private IEnumerable GetMcpReferencedResources(DistributedApplicationM // Toolbox deploy step retains its dependency on the replacement resource. var modelResourceNames = new HashSet( model.Resources.Select(resource => resource.Name), - StringComparer.Ordinal); - var seenResourceNames = new HashSet(StringComparer.Ordinal); + StringComparers.ResourceName); + var seenResourceNames = new HashSet(StringComparers.ResourceName); foreach (var tool in _tools.OfType()) { diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs index 0ecb22fd3eb..63b79540230 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs @@ -173,7 +173,7 @@ internal FoundryToolboxAzureAISearchToolDefinition( string name, AzureSearchResource searchResource, AzureCognitiveServicesProjectConnectionResource connection, - string? indexName) + string indexName) : base(name) { ArgumentNullException.ThrowIfNull(searchResource); @@ -195,9 +195,9 @@ internal FoundryToolboxAzureAISearchToolDefinition( public AzureCognitiveServicesProjectConnectionResource Connection { get; } /// - /// Gets the optional Azure AI Search index name. + /// Gets the Azure AI Search index name. /// - public string? IndexName { get; } + public string IndexName { get; } internal override async ValueTask ResolveAsync(CancellationToken cancellationToken) { diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs index b08d2f785fc..2fa67d686a5 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs @@ -95,7 +95,7 @@ public async Task DeployFoundryToolboxToAzure() foundryProject.AddToolbox("field-tools") .WithDescription("Tools for field technicians.") .WithWebSearchTool() - .WithAISearchTool("knowledge-base", search); + .WithAISearchTool("knowledge-base", search, "docs"); builder.Build().Run(); """); diff --git a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs index 72db2b3e88f..26984ff2e2f 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs @@ -257,7 +257,13 @@ public async Task McpToolDefinition_ConvertsWithLiteralEndpoint() var projectTool = await def.ToProjectsAgentToolAsync(CancellationToken.None); - Assert.NotNull(projectTool); + 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] @@ -343,6 +349,21 @@ public void WithAISearchTool_UsesDeterministicConnectionName() Assert.Equal(firstDefinition.Connection.Name, secondDefinition.Connection.Name); } + [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() { diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts index 998ec2629a9..f28810cc24f 100644 --- a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts @@ -82,11 +82,11 @@ await _promptAgent.withTool(azFunc); await _promptAgent.withTool(funcTool); // Foundry Toolbox -const toolbox = await project.addToolbox('field-tools'); +const toolbox = await project.addToolbox('field-tools', { version: '7' }); await toolbox.withDescription('Tools for field technicians.'); await toolbox.withWebSearchTool(); await toolbox.withMcpTool('inventory', 'https://inventory.example.com/mcp'); -await toolbox.withAISearchTool('knowledge-base', search); +await toolbox.withAISearchTool('knowledge-base', search, 'docs'); const builderProjectFoundry = await builder.addFoundry('builder-project-foundry'); const builderProject = await builderProjectFoundry.addProject('builder-project'); From 3a18d86f02aaa021175131be7a91af1164451da7 Mon Sep 17 00:00:00 2001 From: tommasodotnet Date: Tue, 1 Sep 2026 19:17:46 +0200 Subject: [PATCH 11/30] Address Toolbox endpoint review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting.Foundry/README.md | 32 +++++++++---------- .../FoundryToolboxBuilderExtensions.cs | 5 +-- .../ToolboxTests.cs | 14 ++++++++ 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/src/Aspire.Hosting.Foundry/README.md b/src/Aspire.Hosting.Foundry/README.md index 8f1b8a37fb2..479e2ea02a9 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 @@ -171,26 +185,12 @@ await service.withReference(toolbox); MCP endpoints must be publicly reachable over HTTPS because the Foundry data plane invokes them. For local development, use a public development tunnel instead of a localhost endpoint. Inline -credentials and headers are not supported; configure authenticated MCP servers through Foundry -project connections. +credentials and headers are not supported. Connection-authenticated MCP servers are not currently +supported by this integration. The default consumer endpoint always serves the promoted Toolbox version. Set `FoundryToolboxResource.Version` only when a consumer must target a specific immutable version. -### 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 | - ## 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. diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs index 88d4783b9e9..1a8965fe01e 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs @@ -122,10 +122,11 @@ public static IResourceBuilder WithMcpTool( { ArgumentException.ThrowIfNullOrEmpty(endpoint); if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri) || - endpointUri.Scheme != Uri.UriSchemeHttps) + endpointUri.Scheme != Uri.UriSchemeHttps || + endpointUri.IsLoopback) { throw new ArgumentException( - "The MCP endpoint must be an absolute HTTPS URI.", + "The MCP endpoint must be a publicly reachable absolute HTTPS URI.", nameof(endpoint)); } diff --git a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs index 26984ff2e2f..93e005b757e 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs @@ -324,6 +324,20 @@ public void WithMcpTool_ThrowsImmediatelyWhenLiteralEndpointIsNotHttps() 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); + } + [Fact] public void WithAISearchTool_UsesDeterministicConnectionName() { From 2293d2c7bd853d0edc6cb1aa749aa137f1398ac4 Mon Sep 17 00:00:00 2001 From: tommasodotnet Date: Tue, 1 Sep 2026 19:31:37 +0200 Subject: [PATCH 12/30] Harden Toolbox MCP endpoint validation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../FoundryToolboxBuilderExtensions.cs | 3 +- .../Toolbox/FoundryToolboxToolDefinition.cs | 14 +++++++-- .../ToolboxTests.cs | 31 +++++++++++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs index 1a8965fe01e..8926b5e9c65 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs @@ -122,8 +122,7 @@ public static IResourceBuilder WithMcpTool( { ArgumentException.ThrowIfNullOrEmpty(endpoint); if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri) || - endpointUri.Scheme != Uri.UriSchemeHttps || - endpointUri.IsLoopback) + !FoundryToolboxMcpToolDefinition.IsPublicHttpsEndpoint(endpointUri)) { throw new ArgumentException( "The MCP endpoint must be a publicly reachable absolute HTTPS URI.", diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs index 63b79540230..68d12ce65dc 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs @@ -98,6 +98,17 @@ internal override ValueTask ResolveAsync(Cancellatio /// internal sealed class FoundryToolboxMcpToolDefinition : FoundryToolboxToolDefinition { + internal static bool IsPublicHttpsEndpoint(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) : base(name) { @@ -121,8 +132,7 @@ internal override async ValueTask ResolveAsync(Cance } if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri) || - endpointUri.Scheme != Uri.UriSchemeHttps || - endpointUri.IsLoopback) + !IsPublicHttpsEndpoint(endpointUri)) { throw new InvalidOperationException( $"MCP tool '{Name}' must resolve to a publicly reachable absolute HTTPS endpoint."); diff --git a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs index 93e005b757e..2d6c5fbbc7e 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs @@ -310,6 +310,21 @@ public async Task McpToolDefinition_ThrowsWhenEndpointIsLoopback() Assert.Contains("publicly 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.ToProjectsAgentToolAsync(CancellationToken.None)); + + Assert.Contains("publicly reachable", exception.Message, StringComparison.Ordinal); + } + [Fact] public void WithMcpTool_ThrowsImmediatelyWhenLiteralEndpointIsNotHttps() { @@ -338,6 +353,22 @@ public void WithMcpTool_ThrowsImmediatelyWhenLiteralEndpointIsLoopback() 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() { From b6eb4c3b358ebdcd7c778f2b3f2ade9eb15a6a0f Mon Sep 17 00:00:00 2001 From: tommasodotnet Date: Wed, 2 Sep 2026 12:33:01 +0200 Subject: [PATCH 13/30] Complete Foundry Toolbox lifecycle support Add existing-resource modes, full tool authoring metadata, concurrency checks, and live deployment verification. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting.Foundry/README.md | 72 ++- .../FoundryToolboxBuilderExtensions.cs | 113 ++++- .../Toolbox/FoundryToolboxOptions.cs | 68 +++ .../Toolbox/FoundryToolboxReconciler.cs | 178 ++++++-- .../Toolbox/FoundryToolboxResource.cs | 106 ++++- .../Toolbox/FoundryToolboxToolDefinition.cs | 178 +++++++- .../Aspire.Deployment.EndToEnd.Tests.csproj | 2 + .../FoundryHostedAgentDeploymentTests.cs | 432 +++++++++++++++++- .../FoundryToolboxReconcilerTests.cs | 235 +++++++++- .../ToolboxTests.cs | 211 ++++++++- .../TypeScript/apphost.mts | 28 +- tests/Shared/Hex1bAutomatorTestHelpers.cs | 20 +- 12 files changed, 1544 insertions(+), 99 deletions(-) diff --git a/src/Aspire.Hosting.Foundry/README.md b/src/Aspire.Hosting.Foundry/README.md index 479e2ea02a9..d870fb138b8 100644 --- a/src/Aspire.Hosting.Foundry/README.md +++ b/src/Aspire.Hosting.Foundry/README.md @@ -158,39 +158,93 @@ var search = builder.AddAzureSearch("search"); var toolbox = project.AddToolbox("field-tools") .WithDescription("Tools for field technicians.") - .WithWebSearchTool() + .WithWebSearchTool("web-search", "Search the public web.") .WithAISearchTool("knowledge-base", search, "docs") - .WithMcpTool("inventory", "https://inventory.example.com/mcp"); + .WithMcpTool( + "inventory", + "https://inventory.example.com/mcp", + new FoundryToolboxMcpToolOptions + { + ServerDescription = "Inventory MCP server.", + ApprovalPolicy = new() + { + Global = FoundryToolboxMcpGlobalApprovalMode.Always + } + }); builder.AddProject("service") - .WithReference(toolbox); + .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(); +await toolbox.withWebSearchTool({ + name: "web-search", + description: "Search the public web." +}); await toolbox.withAISearchTool("knowledge-base", search, "docs"); -await toolbox.withMcpTool("inventory", "https://inventory.example.com/mcp"); +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); ``` -MCP endpoints must be publicly reachable over HTTPS because the Foundry data plane invokes them. -For local development, use a public 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 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 +{ + AlwaysRequireApprovalFor = ["delete-item", "update-item"], + NeverRequireApprovalFor = ["get-item"] +} +``` 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. diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs index 8926b5e9c65..06aa5db86a3 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs @@ -66,22 +66,98 @@ internal static IResourceBuilder AddToolboxForPolyglot( 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) + { + builder.WithAnnotation(new FoundryToolboxExistingResourceAnnotation()); + } + + 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) + { + builder.WithAnnotation(new FoundryToolboxExistingResourceAnnotation()); + } + + 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); + + return builder.WithAnnotation(new FoundryToolboxExistingResourceAnnotation()); + } + /// /// 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 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)); + builder.Resource.AddTool(new FoundryToolboxWebSearchToolDefinition(name, description)); return builder; } @@ -112,24 +188,26 @@ public static IResourceBuilder WithDescription( /// 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) + string endpoint, + FoundryToolboxMcpToolOptions? options = null) { ArgumentException.ThrowIfNullOrEmpty(endpoint); if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri) || - !FoundryToolboxMcpToolDefinition.IsPublicHttpsEndpoint(endpointUri)) + !FoundryToolboxMcpToolDefinition.IsFoundryReachableHttpsEndpoint(endpointUri)) { throw new ArgumentException( - "The MCP endpoint must be a publicly reachable absolute HTTPS URI.", + "The MCP endpoint must be a Foundry-reachable absolute HTTPS URI.", nameof(endpoint)); } - return builder.WithMcpTool(name, ReferenceExpression.Create($"{endpointUri.AbsoluteUri}")); + return builder.WithMcpTool(name, ReferenceExpression.Create($"{endpointUri.AbsoluteUri}"), options); } /// @@ -138,8 +216,9 @@ public static IResourceBuilder WithMcpTool( /// 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 publicly reachable HTTPS URI, such + /// 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. @@ -150,11 +229,12 @@ public static IResourceBuilder WithMcpTool( public static IResourceBuilder WithMcpTool( this IResourceBuilder builder, string name, - EndpointReference endpoint) + EndpointReference endpoint, + FoundryToolboxMcpToolOptions? options = null) { ArgumentNullException.ThrowIfNull(endpoint); - return builder.WithMcpTool(name, ReferenceExpression.Create($"{endpoint}")); + return builder.WithMcpTool(name, ReferenceExpression.Create($"{endpoint}"), options); } /// @@ -166,24 +246,26 @@ public static IResourceBuilder WithMcpTool( /// 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) + [AspireUnion(typeof(string), typeof(EndpointReference), typeof(ReferenceExpression))] object endpoint, + FoundryToolboxMcpToolOptions? options = null) { ArgumentNullException.ThrowIfNull(endpoint); return endpoint switch { - string endpointString => builder.WithMcpTool(name, endpointString), - EndpointReference endpointReference => builder.WithMcpTool(name, endpointReference), + 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), + ReferenceExpression endpointExpression => builder.WithMcpTool(name, endpointExpression, options), _ => throw new ArgumentException("Endpoint must be a string, endpoint reference, or reference expression.", nameof(endpoint)) }; } @@ -243,13 +325,14 @@ private static string CreateSearchConnectionName( private static IResourceBuilder WithMcpTool( this IResourceBuilder builder, string name, - ReferenceExpression endpointExpression) + ReferenceExpression endpointExpression, + FoundryToolboxMcpToolOptions? options) { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(name); ArgumentNullException.ThrowIfNull(endpointExpression); - builder.Resource.AddTool(new FoundryToolboxMcpToolDefinition(name, 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 index 637230025ef..87539fd1f2e 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxOptions.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxOptions.cs @@ -15,3 +15,71 @@ internal sealed class FoundryToolboxOptions /// 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 sent to the MCP server. 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 MCP tool names that always require approval. + /// + public string[]? AlwaysRequireApprovalFor { get; set; } + + /// + /// Gets or sets the MCP tool names that never require approval. + /// + public string[]? NeverRequireApprovalFor { 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/FoundryToolboxReconciler.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReconciler.cs index 69808dd814e..44df99ab347 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReconciler.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReconciler.cs @@ -77,6 +77,19 @@ public static FoundryToolboxDeploymentDefinition Create( $"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) { @@ -142,7 +155,8 @@ private static string ComputeConfigurationHash( internal sealed record ResolvedFoundryToolboxTool( string Name, ProjectsAgentTool Tool, - string CanonicalConfiguration); + string CanonicalConfiguration, + string? McpServerLabel = null); internal sealed record FoundryToolboxState( string DefaultVersion, @@ -296,27 +310,11 @@ public async Task ReconcileAsync( if (existing is null) { var created = await administration.CreateVersionAsync(definition, cancellationToken).ConfigureAwait(false); - // Another deployment can create the same Toolbox between the read and create calls. - // Explicitly promote our immutable version so the unversioned consumer endpoint serves - // the configuration this deployment just reconciled. - await administration.PromoteVersionAsync( - definition.Name, - created, - cancellationToken).ConfigureAwait(false); + await PromoteOwnedVersionAsync(definition, created, cancellationToken).ConfigureAwait(false); return new(created, FoundryToolboxReconcileAction.CreatedAndPromoted); } - if (!existing.Default.Metadata.TryGetValue( - FoundryToolboxDeploymentDefinition.ManagedByMetadataKey, - out var managedBy) || - !string.Equals( - managedBy, - FoundryToolboxDeploymentDefinition.ManagedByMetadataValue, - StringComparison.Ordinal)) - { - throw new InvalidOperationException( - $"Toolbox '{definition.Name}' already exists but is not managed by Aspire. No changes were made."); - } + ValidateOwnedDefault(definition.Name, existing); if (existing.Default.Metadata.TryGetValue( FoundryToolboxDeploymentDefinition.ConfigurationHashMetadataKey, @@ -340,8 +338,8 @@ await administration.PromoteVersionAsync( string.Equals(versionHash, definition.ConfigurationHash, StringComparison.Ordinal)); if (reusableVersion is not null) { - await administration.PromoteVersionAsync( - definition.Name, + await PromoteOwnedVersionAsync( + definition, reusableVersion.Version, cancellationToken).ConfigureAwait(false); return new(reusableVersion.Version, FoundryToolboxReconcileAction.Promoted); @@ -350,14 +348,141 @@ await administration.PromoteVersionAsync( var updated = await administration.CreateVersionAsync(definition, cancellationToken).ConfigureAwait(false); if (!string.Equals(existing.DefaultVersion, updated, StringComparison.Ordinal)) { - await administration.PromoteVersionAsync( - definition.Name, - updated, - cancellationToken).ConfigureAwait(false); + await PromoteOwnedVersionAsync(definition, updated, cancellationToken).ConfigureAwait(false); } return new(updated, FoundryToolboxReconcileAction.CreatedAndPromoted); } + + private async Task PromoteOwnedVersionAsync( + FoundryToolboxDeploymentDefinition definition, + string version, + CancellationToken cancellationToken) + { + // Toolbox administration has no conditional update or ETag support. Re-read immediately + // before promotion so a concurrent writer cannot silently replace a foreign default, then + // verify the write before reporting success. 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); + + 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( @@ -368,5 +493,6 @@ internal enum FoundryToolboxReconcileAction { Reused, Promoted, - CreatedAndPromoted + CreatedAndPromoted, + ValidatedExisting } diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs index 4b69ade463d..21059ea5cf9 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs @@ -57,7 +57,9 @@ public FoundryToolboxResource( steps.Add(new PipelineStep { Name = $"deploy-{Name}-before-start", - Description = $"Reconciles Toolbox {Name} after the application starts.", + Description = IsExisting + ? $"Validates existing Toolbox {Name} after the application starts." + : $"Reconciles Toolbox {Name} after the application starts.", Action = DeployBeforeStartAsync, RequiredBySteps = [BeforeStartStepName], Resource = this, @@ -68,17 +70,21 @@ public FoundryToolboxResource( steps.Add(new PipelineStep { Name = $"deploy-{Name}", - Description = $"Reconciles Toolbox {Name}.", + Description = IsExisting + ? $"Validates existing Toolbox {Name}." + : $"Reconciles Toolbox {Name}.", Action = async stepContext => { - var result = await ReconcileAsync( + var result = await DeployAsync( stepContext, message => stepContext.Logger.LogWarning("{Message}", message), stepContext.CancellationToken).ConfigureAwait(false); stepContext.ReportingStep.Log( LogLevel.Information, new MarkdownString( - $"Successfully reconciled **{Name}** as Foundry Toolbox (version {result.Version}, action {result.Action})")); + 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], @@ -97,6 +103,11 @@ public FoundryToolboxResource( // 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)) @@ -154,6 +165,9 @@ public FoundryToolboxResource( /// internal IReadOnlyList Tools => _tools; + internal bool IsExisting => + Annotations.OfType().LastOrDefault() is not null; + /// /// Gets the Toolbox MCP endpoint URI expression. /// @@ -210,7 +224,34 @@ IEnumerable> IResourceWithConnectionSt } } - private async Task ReconcileAsync( + 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, cancellationToken).ConfigureAwait(false); + DeployedVersion.Set(result.Version); + + return result; + } + + private async Task CreateAdministrationAsync( PipelineStepContext context, Action logRetry, CancellationToken cancellationToken) @@ -237,12 +278,7 @@ private async Task ReconcileAsync( logRetry); } - var definition = await CreateDeploymentDefinitionAsync(cancellationToken).ConfigureAwait(false); - var result = await new FoundryToolboxReconciler(administration) - .ReconcileAsync(definition, cancellationToken).ConfigureAwait(false); - DeployedVersion.Set(result.Version); - - return result; + return administration; } private Task DeployBeforeStartAsync(PipelineStepContext context) @@ -299,27 +335,39 @@ await WaitForProjectAndToolsAsync( await notificationService.PublishUpdateAsync(this, snapshot => snapshot with { - State = new("Reconciling Toolbox", KnownResourceStateStyles.Info) + State = new( + IsExisting ? "Validating existing Toolbox" : "Reconciling Toolbox", + KnownResourceStateStyles.Info) }).ConfigureAwait(false); - var result = await ReconcileAsync( + var result = await DeployAsync( context, message => logger.LogWarning("{Message}", message), cancellationToken).ConfigureAwait(false); - logger.LogInformation( - "Reconciled Toolbox '{ToolboxName}' at version {Version} with action {Action}.", - Name, - result.Version, - result.Action); + 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("Running", KnownResourceStateStyles.Success), + State = new(KnownResourceStates.Running, KnownResourceStateStyles.Success), Properties = [ new("Toolbox version", result.Version), - new("Reconciliation action", result.Action.ToString()) + new(IsExisting ? "Validation action" : "Reconciliation action", result.Action.ToString()) ] }).ConfigureAwait(false); } @@ -328,11 +376,18 @@ await notificationService.PublishUpdateAsync(this, snapshot => snapshot with } catch (Exception ex) { - logger.LogError(ex, "Failed to reconcile Toolbox '{ToolboxName}'.", Name); + 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("Failed to reconcile", KnownResourceStateStyles.Error) + State = new(KnownResourceStates.FailedToStart, KnownResourceStateStyles.Error) }).ConfigureAwait(false); } } @@ -354,6 +409,11 @@ await notificationService.WaitForResourceAsync( cancellationToken).ConfigureAwait(false); } + if (IsExisting) + { + return; + } + var connectionProvisioningTasks = _tools .OfType() .Select(tool => tool.Connection.ProvisioningTaskCompletionSource?.Task) @@ -424,6 +484,8 @@ private static IEnumerable WalkValueReferences(object root) } } +internal sealed class FoundryToolboxExistingResourceAnnotation : IResourceAnnotation; + internal sealed class FoundryToolboxFeaturesPolicy : PipelinePolicy { private const string HeaderName = "Foundry-Features"; diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs index 68d12ce65dc..153e4a4dc78 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs @@ -45,11 +45,14 @@ internal async ValueTask ToProjectsAgentToolAsync(Cancellatio /// internal sealed class FoundryToolboxWebSearchToolDefinition : FoundryToolboxToolDefinition { - internal FoundryToolboxWebSearchToolDefinition(string name) + 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 @@ -82,11 +85,20 @@ internal override ValueTask ResolveAsync(Cancellatio // 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. - var json = BinaryData.FromObjectAsJson(new + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) { - type = "web_search", - name = Name - }); + 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())); @@ -98,7 +110,7 @@ internal override ValueTask ResolveAsync(Cancellatio /// internal sealed class FoundryToolboxMcpToolDefinition : FoundryToolboxToolDefinition { - internal static bool IsPublicHttpsEndpoint(Uri endpointUri) + internal static bool IsFoundryReachableHttpsEndpoint(Uri endpointUri) { var host = endpointUri.Host.TrimEnd('.'); @@ -109,12 +121,24 @@ internal static bool IsPublicHttpsEndpoint(Uri endpointUri) !host.EndsWith(".localhost", StringComparison.OrdinalIgnoreCase); } - internal FoundryToolboxMcpToolDefinition(string name, ReferenceExpression endpointExpression) + 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); + } } /// @@ -122,6 +146,12 @@ internal FoundryToolboxMcpToolDefinition(string name, ReferenceExpression endpoi /// 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); @@ -132,10 +162,10 @@ internal override async ValueTask ResolveAsync(Cance } if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri) || - !IsPublicHttpsEndpoint(endpointUri)) + !IsFoundryReachableHttpsEndpoint(endpointUri)) { throw new InvalidOperationException( - $"MCP tool '{Name}' must resolve to a publicly reachable absolute HTTPS endpoint."); + $"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 @@ -159,8 +189,17 @@ internal override async ValueTask ResolveAsync(Cance { writer.WriteStartObject(); writer.WriteString("type", "mcp"); - writer.WriteString("server_label", Name); + 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(); } @@ -170,7 +209,124 @@ internal override async ValueTask ResolveAsync(Cance ModelReaderWriterOptions.Json, AzureAIProjectsAgentsContext.Default)!; - return new ResolvedFoundryToolboxTool(Name, tool, json.ToString()); + return new ResolvedFoundryToolboxTool(Name, tool, json.ToString(), ServerLabel); + } +} + +internal sealed record ResolvedFoundryToolboxMcpApprovalPolicy( + FoundryToolboxMcpGlobalApprovalMode? Global, + IReadOnlyList AlwaysRequireApprovalFor, + IReadOnlyList NeverRequireApprovalFor) +{ + public static ResolvedFoundryToolboxMcpApprovalPolicy? Create( + FoundryToolboxMcpApprovalPolicy? policy) + { + if (policy is null) + { + return null; + } + + var always = NormalizeToolNames( + policy.AlwaysRequireApprovalFor, + nameof(policy.AlwaysRequireApprovalFor)); + var never = NormalizeToolNames( + policy.NeverRequireApprovalFor, + nameof(policy.NeverRequireApprovalFor)); + + if (policy.Global is not null && (always.Count > 0 || never.Count > 0)) + { + throw new ArgumentException( + "A global MCP approval policy cannot be combined with custom tool-name filters.", + nameof(policy)); + } + + if (policy.Global is null && always.Count == 0 && never.Count == 0) + { + throw new ArgumentException( + "An MCP approval policy must specify a global mode or at least one custom tool-name 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.Intersect(never, StringComparer.Ordinal).FirstOrDefault(); + if (overlap is not null) + { + throw new ArgumentException( + $"MCP tool '{overlap}' 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(); + WriteToolFilter(writer, "always", AlwaysRequireApprovalFor); + WriteToolFilter(writer, "never", NeverRequireApprovalFor); + writer.WriteEndObject(); + } + + private static IReadOnlyList NormalizeToolNames( + IEnumerable? names, + string parameterName) + { + if (names is null) + { + return []; + } + + var normalized = names + .Select(name => + { + ArgumentException.ThrowIfNullOrWhiteSpace(name, parameterName); + return name; + }) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(); + + return normalized; + } + + private static void WriteToolFilter( + Utf8JsonWriter writer, + string propertyName, + IReadOnlyList toolNames) + { + if (toolNames.Count == 0) + { + return; + } + + writer.WriteStartObject(propertyName); + writer.WriteStartArray("tool_names"); + foreach (var toolName in toolNames) + { + writer.WriteStringValue(toolName); + } + writer.WriteEndArray(); + writer.WriteEndObject(); } } 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 2fa67d686a5..98b7e96e231 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; @@ -94,7 +107,18 @@ public async Task DeployFoundryToolboxToAzure() foundryProject.AddToolbox("field-tools") .WithDescription("Tools for field technicians.") - .WithWebSearchTool() + .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"); builder.Build().Run(); @@ -105,7 +129,9 @@ public async Task DeployFoundryToolboxToAzure() await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); await auto.TypeAsync( - $"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}"); + $"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); @@ -113,16 +139,42 @@ await auto.TypeAsync( // immutable version rather than producing another one. await auto.TypeAsync("aspire deploy --clear-cache"); await auto.EnterAsync(); - await auto.WaitUntilTextAsync("action CreatedAndPromoted", timeout: TimeSpan.FromMinutes(35)); - await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(2)); + 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.NotEmpty(discoveredTools); + await auto.TypeAsync("aspire deploy --clear-cache"); await auto.EnterAsync(); - await auto.WaitUntilTextAsync("action Reused", timeout: TimeSpan.FromMinutes(15)); - await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(2)); + 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(); @@ -158,6 +210,374 @@ await auto.TypeAsync( } } + 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") + } + }; + for (var attempt = 0; ; attempt++) + { + try + { + await indexClient.CreateOrUpdateIndexAsync(index, cancellationToken: cancellationToken); + break; + } + catch (RequestFailedException ex) when (ex.Status == 403 && attempt < 11) + { + // Azure RBAC propagation is eventually consistent after the test principal receives + // Search Service Contributor on the newly provisioned service. + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + } + } + } + + 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, + """ + {"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, + """{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}""", + cancellationToken); + var tools = await SendMcpRequestAsync( + client, + endpoint, + accessToken.Token, + initialize.SessionId, + """{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}""", + cancellationToken); + + return tools.Result.GetProperty("tools") + .EnumerateArray() + .Select(tool => tool.GetProperty("name").GetString() + ?? throw new InvalidOperationException("A discovered MCP tool did not have a name.")) + .ToArray(); + } + + private static async Task SendMcpRequestAsync( + HttpClient client, + Uri endpoint, + string accessToken, + string? sessionId, + 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); + } + 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 index dcb6deb1d77..0871157fcb5 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/FoundryToolboxReconcilerTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/FoundryToolboxReconcilerTests.cs @@ -1,6 +1,8 @@ // 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 @@ -109,6 +111,88 @@ public async Task ReconcileAsync_PromotesMatchingExistingVersion() 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_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() { @@ -137,6 +221,38 @@ public async Task Create_ProducesStableConfigurationHash() 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() { @@ -153,6 +269,26 @@ public async Task Create_RejectsDuplicateToolNames() 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() { @@ -184,6 +320,74 @@ public async Task Create_RejectsFourteenUserMetadataEntries() 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") @@ -196,6 +400,14 @@ private static async Task CreateDefinitionAs new Dictionary()); } + private static FoundryToolboxDeploymentDefinition CreateDefinition( + IReadOnlyList tools) => + FoundryToolboxDeploymentDefinition.Create( + "field-tools", + "Description", + tools, + new Dictionary()); + private static FoundryToolboxState CreateExistingState( FoundryToolboxDeploymentDefinition definition, string version) => @@ -217,22 +429,37 @@ private static FoundryToolboxVersionState CreateVersionState(string version, str private sealed class RecordingToolboxAdministration : IFoundryToolboxAdministration { - public FoundryToolboxState? Existing { get; init; } + 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(Existing); + 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); } @@ -242,6 +469,10 @@ public Task PromoteVersionAsync( 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/ToolboxTests.cs b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs index 2d6c5fbbc7e..2be9bc24f95 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs @@ -33,6 +33,59 @@ public void AddToolbox_CreatesProjectChildResource() Assert.Same(project.Resource, toolbox.Resource.Parent); } + [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 WithToolMethods_AddToolDefinitions() { @@ -214,6 +267,24 @@ public async Task WebSearchToolDefinition_ConvertsToProjectsAgentTool() 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.ToProjectsAgentToolAsync(CancellationToken.None); + + 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() { @@ -266,6 +337,98 @@ public async Task McpToolDefinition_ConvertsWithLiteralEndpoint() 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.ToProjectsAgentToolAsync(CancellationToken.None); + + 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() + { + AlwaysRequireApprovalFor = ["write", "delete", "write"], + NeverRequireApprovalFor = ["read"] + } + }); + var definition = Assert.IsType( + Assert.Single(toolbox.Resource.Tools)); + + var projectTool = await definition.ToProjectsAgentToolAsync(CancellationToken.None); + + 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"]},"never":{"tool_names":["read"]}}}""", + 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, + NeverRequireApprovalFor = ["read"] + } + })); + + Assert.Contains( + "cannot be combined with custom tool-name filters", + exception.Message, + StringComparison.Ordinal); + } + [Fact] public async Task McpToolDefinition_ThrowsWhenEndpointUnresolved() { @@ -294,7 +457,7 @@ public async Task McpToolDefinition_ThrowsWhenEndpointIsNotHttps() var ex = await Assert.ThrowsAsync( async () => await def.ToProjectsAgentToolAsync(CancellationToken.None)); - Assert.Contains("publicly reachable absolute HTTPS endpoint", ex.Message, StringComparison.Ordinal); + Assert.Contains("Foundry-reachable absolute HTTPS endpoint", ex.Message, StringComparison.Ordinal); } [Fact] @@ -307,7 +470,7 @@ public async Task McpToolDefinition_ThrowsWhenEndpointIsLoopback() var exception = await Assert.ThrowsAsync( async () => await def.ToProjectsAgentToolAsync(CancellationToken.None)); - Assert.Contains("publicly reachable", exception.Message, StringComparison.Ordinal); + Assert.Contains("Foundry-reachable", exception.Message, StringComparison.Ordinal); } [Theory] @@ -322,7 +485,7 @@ public async Task McpToolDefinition_ThrowsWhenEndpointIsNotPubliclyReachable(str var exception = await Assert.ThrowsAsync( async () => await def.ToProjectsAgentToolAsync(CancellationToken.None)); - Assert.Contains("publicly reachable", exception.Message, StringComparison.Ordinal); + Assert.Contains("Foundry-reachable", exception.Message, StringComparison.Ordinal); } [Fact] @@ -499,6 +662,48 @@ public async Task AddToolbox_McpTool_PublishConfigurationAnnotation_LiteralEndpo 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); + } + private static PipelineContext CreatePipelineContext(DistributedApplication app, DistributedApplicationOperation operation) { var model = app.Services.GetRequiredService(); diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts index f28810cc24f..5c5c6aafd00 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(); @@ -84,10 +84,32 @@ 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(); -await toolbox.withMcpTool('inventory', 'https://inventory.example.com/mcp'); +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: { + alwaysRequireApprovalFor: ['delete-item'], + neverRequireApprovalFor: ['get-item'] + } +}); 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' }); 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) { From 017cc0ad09303c383d955f408a30e33c76ac6ab0 Mon Sep 17 00:00:00 2001 From: tommasodotnet Date: Wed, 2 Sep 2026 12:38:51 +0200 Subject: [PATCH 14/30] Support complete MCP approval filters Preserve tool-name and read-only selectors in both custom approval branches. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting.Foundry/README.md | 12 ++- .../Toolbox/FoundryToolboxOptions.cs | 28 ++++-- .../Toolbox/FoundryToolboxToolDefinition.cs | 86 ++++++++++++------- .../ToolboxTests.cs | 47 ++++++++-- .../TypeScript/apphost.mts | 10 ++- 5 files changed, 136 insertions(+), 47 deletions(-) diff --git a/src/Aspire.Hosting.Foundry/README.md b/src/Aspire.Hosting.Foundry/README.md index d870fb138b8..b6b3b30340b 100644 --- a/src/Aspire.Hosting.Foundry/README.md +++ b/src/Aspire.Hosting.Foundry/README.md @@ -217,8 +217,16 @@ individual MCP tool names: ```csharp new FoundryToolboxMcpApprovalPolicy { - AlwaysRequireApprovalFor = ["delete-item", "update-item"], - NeverRequireApprovalFor = ["get-item"] + Always = new() + { + ToolNames = ["delete-item", "update-item"], + ReadOnly = false + }, + Never = new() + { + ToolNames = ["get-item"], + ReadOnly = true + } } ``` diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxOptions.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxOptions.cs index 87539fd1f2e..16d0d53952b 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxOptions.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxOptions.cs @@ -52,20 +52,36 @@ 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 - /// . + /// This cannot be combined with or . /// public FoundryToolboxMcpGlobalApprovalMode? Global { get; set; } /// - /// Gets or sets the MCP tool names that always require approval. + /// Gets or sets the filter for MCP tools that always require approval. /// - public string[]? AlwaysRequireApprovalFor { get; set; } + public FoundryToolboxMcpApprovalFilter? Always { get; set; } /// - /// Gets or sets the MCP tool names that never require approval. + /// Gets or sets the filter for MCP tools that never require approval. /// - public string[]? NeverRequireApprovalFor { get; set; } + 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; } } /// diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs index 153e4a4dc78..0abbc2ed643 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs @@ -215,8 +215,8 @@ internal override async ValueTask ResolveAsync(Cance internal sealed record ResolvedFoundryToolboxMcpApprovalPolicy( FoundryToolboxMcpGlobalApprovalMode? Global, - IReadOnlyList AlwaysRequireApprovalFor, - IReadOnlyList NeverRequireApprovalFor) + ResolvedFoundryToolboxMcpApprovalFilter? Always, + ResolvedFoundryToolboxMcpApprovalFilter? Never) { public static ResolvedFoundryToolboxMcpApprovalPolicy? Create( FoundryToolboxMcpApprovalPolicy? policy) @@ -226,24 +226,24 @@ internal sealed record ResolvedFoundryToolboxMcpApprovalPolicy( return null; } - var always = NormalizeToolNames( - policy.AlwaysRequireApprovalFor, - nameof(policy.AlwaysRequireApprovalFor)); - var never = NormalizeToolNames( - policy.NeverRequireApprovalFor, - nameof(policy.NeverRequireApprovalFor)); + 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.Count > 0 || never.Count > 0)) + 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 tool-name filters.", + "A global MCP approval policy cannot be combined with custom filters.", nameof(policy)); } - if (policy.Global is null && always.Count == 0 && never.Count == 0) + 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 tool-name filter.", + "An MCP approval policy must specify a global mode or at least one custom filter.", nameof(policy)); } @@ -257,7 +257,9 @@ policy.Global is not FoundryToolboxMcpGlobalApprovalMode.Never && "The global MCP approval mode is not supported."); } - var overlap = always.Intersect(never, StringComparer.Ordinal).FirstOrDefault(); + var overlap = always?.ToolNames + .Intersect(never?.ToolNames ?? [], StringComparer.Ordinal) + .FirstOrDefault(); if (overlap is not null) { throw new ArgumentException( @@ -265,6 +267,13 @@ policy.Global is not FoundryToolboxMcpGlobalApprovalMode.Never && 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); } @@ -282,21 +291,26 @@ public void WriteTo(Utf8JsonWriter writer) } writer.WriteStartObject(); - WriteToolFilter(writer, "always", AlwaysRequireApprovalFor); - WriteToolFilter(writer, "never", NeverRequireApprovalFor); + Always?.WriteTo(writer, "always"); + Never?.WriteTo(writer, "never"); writer.WriteEndObject(); } +} - private static IReadOnlyList NormalizeToolNames( - IEnumerable? names, +internal sealed record ResolvedFoundryToolboxMcpApprovalFilter( + IReadOnlyList ToolNames, + bool? ReadOnly) +{ + public static ResolvedFoundryToolboxMcpApprovalFilter? Create( + FoundryToolboxMcpApprovalFilter? filter, string parameterName) { - if (names is null) + if (filter is null) { - return []; + return null; } - var normalized = names + var toolNames = (filter.ToolNames ?? []) .Select(name => { ArgumentException.ThrowIfNullOrWhiteSpace(name, parameterName); @@ -306,26 +320,34 @@ private static IReadOnlyList NormalizeToolNames( .Order(StringComparer.Ordinal) .ToArray(); - return normalized; + 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); } - private static void WriteToolFilter( - Utf8JsonWriter writer, - string propertyName, - IReadOnlyList toolNames) + public void WriteTo(Utf8JsonWriter writer, string propertyName) { - if (toolNames.Count == 0) + writer.WriteStartObject(propertyName); + if (ToolNames.Count > 0) { - return; + writer.WriteStartArray("tool_names"); + foreach (var toolName in ToolNames) + { + writer.WriteStringValue(toolName); + } + writer.WriteEndArray(); } - writer.WriteStartObject(propertyName); - writer.WriteStartArray("tool_names"); - foreach (var toolName in toolNames) + if (ReadOnly is { } readOnly) { - writer.WriteStringValue(toolName); + writer.WriteBoolean("read_only", readOnly); } - writer.WriteEndArray(); + writer.WriteEndObject(); } } diff --git a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs index 2be9bc24f95..f313820840f 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs @@ -384,8 +384,16 @@ public async Task McpToolDefinition_IncludesCanonicalCustomApproval() { ApprovalPolicy = new() { - AlwaysRequireApprovalFor = ["write", "delete", "write"], - NeverRequireApprovalFor = ["read"] + Always = new() + { + ToolNames = ["write", "delete", "write"], + ReadOnly = false + }, + Never = new() + { + ToolNames = ["read"], + ReadOnly = true + } } }); var definition = Assert.IsType( @@ -398,7 +406,7 @@ public async Task McpToolDefinition_IncludesCanonicalCustomApproval() 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"]},"never":{"tool_names":["read"]}}}""", + """{"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()); } @@ -419,12 +427,41 @@ public void WithMcpTool_RejectsMixedGlobalAndCustomApproval() ApprovalPolicy = new() { Global = FoundryToolboxMcpGlobalApprovalMode.Always, - NeverRequireApprovalFor = ["read"] + 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( - "cannot be combined with custom tool-name filters", + "must specify at least one tool name or a read-only value", exception.Message, StringComparison.Ordinal); } diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts index 5c5c6aafd00..309d1fffc43 100644 --- a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts @@ -97,8 +97,14 @@ await toolbox.withMcpTool('inventory', 'https://inventory.example.com/mcp', { }); await toolbox.withMcpTool('inventory-custom', 'https://inventory.example.com/mcp', { approvalPolicy: { - alwaysRequireApprovalFor: ['delete-item'], - neverRequireApprovalFor: ['get-item'] + always: { + toolNames: ['delete-item'], + readOnly: false + }, + never: { + toolNames: ['get-item'], + readOnly: true + } } }); await toolbox.withAISearchTool('knowledge-base', search, 'docs'); From 856c29c6b3c1ae413ef8a352c2a7dd35382c9c19 Mon Sep 17 00:00:00 2001 From: tommasodotnet Date: Wed, 2 Sep 2026 12:50:39 +0200 Subject: [PATCH 15/30] Fix Toolbox Search identity grants Emit Foundry project Search roles directly and harden the two-deployment test timing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Project/ConnectionBuilderExtensions.cs | 75 +++++++++++++++---- .../FoundryToolboxBuilderExtensions.cs | 5 -- .../FoundryHostedAgentDeploymentTests.cs | 10 ++- .../ToolboxTests.cs | 19 +++++ 4 files changed, 87 insertions(+), 22 deletions(-) diff --git a/src/Aspire.Hosting.Foundry/Project/ConnectionBuilderExtensions.cs b/src/Aspire.Hosting.Foundry/Project/ConnectionBuilderExtensions.cs index eff43b33952..ba2d55e700f 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 }); } @@ -214,21 +223,40 @@ internal static IResourceBuilder - { - 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); + }); } /// @@ -239,12 +267,27 @@ 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); + 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/Toolbox/FoundryToolboxBuilderExtensions.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs index 06aa5db86a3..eb99b850cba 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs @@ -6,7 +6,6 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure; using Aspire.Hosting.Foundry; -using Azure.Provisioning.Search; namespace Aspire.Hosting; @@ -292,10 +291,6 @@ public static IResourceBuilder WithAISearchTool( ArgumentException.ThrowIfNullOrWhiteSpace(indexName); var projectBuilder = builder.ApplicationBuilder.CreateResourceBuilder(builder.Resource.Parent); - projectBuilder.WithRoleAssignments( - search, - SearchBuiltInRole.SearchIndexDataReader, - SearchBuiltInRole.SearchServiceContributor); var connectionName = CreateSearchConnectionName( builder.Resource.Parent.Name, builder.Resource.Name, diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs index 98b7e96e231..d5654bfca44 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs @@ -30,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() @@ -45,7 +48,7 @@ public async Task DeployFoundryHostedAgentToAzure() [Fact] public async Task DeployFoundryToolboxToAzure() { - using var cts = new CancellationTokenSource(s_testTimeout); + using var cts = new CancellationTokenSource(s_toolboxTestTimeout); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( cts.Token, TestContext.Current.CancellationToken); var cancellationToken = linkedCts.Token; @@ -162,6 +165,11 @@ await auto.TypeAsync( cancellationToken); Assert.NotEmpty(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); diff --git a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs index f313820840f..82b7068cbe4 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs @@ -594,6 +594,25 @@ public void WithAISearchTool_UsesDeterministicConnectionName() 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() { From 5dfcf40956d9f6de48a145edb5440e5b5e0c39ba Mon Sep 17 00:00:00 2001 From: tommasodotnet Date: Wed, 2 Sep 2026 13:05:03 +0200 Subject: [PATCH 16/30] Prevent concurrent Toolbox default overwrite Require the default observed during reconciliation to remain unchanged before promotion, including initial creation races. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Toolbox/FoundryToolboxReconciler.cs | 36 +++++++++-- .../FoundryToolboxReconcilerTests.cs | 61 ++++++++++++++++++- 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReconciler.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReconciler.cs index 44df99ab347..58f66c1a44b 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReconciler.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReconciler.cs @@ -310,7 +310,11 @@ public async Task ReconcileAsync( if (existing is null) { var created = await administration.CreateVersionAsync(definition, cancellationToken).ConfigureAwait(false); - await PromoteOwnedVersionAsync(definition, created, cancellationToken).ConfigureAwait(false); + await PromoteOwnedVersionAsync( + definition, + created, + observedDefaultVersion: null, + cancellationToken).ConfigureAwait(false); return new(created, FoundryToolboxReconcileAction.CreatedAndPromoted); } @@ -341,6 +345,7 @@ public async Task ReconcileAsync( await PromoteOwnedVersionAsync( definition, reusableVersion.Version, + existing.DefaultVersion, cancellationToken).ConfigureAwait(false); return new(reusableVersion.Version, FoundryToolboxReconcileAction.Promoted); } @@ -348,7 +353,11 @@ await PromoteOwnedVersionAsync( var updated = await administration.CreateVersionAsync(definition, cancellationToken).ConfigureAwait(false); if (!string.Equals(existing.DefaultVersion, updated, StringComparison.Ordinal)) { - await PromoteOwnedVersionAsync(definition, updated, cancellationToken).ConfigureAwait(false); + await PromoteOwnedVersionAsync( + definition, + updated, + existing.DefaultVersion, + cancellationToken).ConfigureAwait(false); } return new(updated, FoundryToolboxReconcileAction.CreatedAndPromoted); @@ -357,12 +366,14 @@ await PromoteOwnedVersionAsync( 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 so a concurrent writer cannot silently replace a foreign default, then - // verify the write before reporting success. This is coordination, not an atomic lock: - // another writer can still update the Toolbox after the final read. + // 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, @@ -370,6 +381,21 @@ private async Task PromoteOwnedVersionAsync( 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, diff --git a/tests/Aspire.Hosting.Foundry.Tests/FoundryToolboxReconcilerTests.cs b/tests/Aspire.Hosting.Foundry.Tests/FoundryToolboxReconcilerTests.cs index 0871157fcb5..3441df4a8bb 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/FoundryToolboxReconcilerTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/FoundryToolboxReconcilerTests.cs @@ -22,7 +22,7 @@ public async Task ReconcileAsync_CreatesFirstVersion() Assert.Equal(FoundryToolboxReconcileAction.CreatedAndPromoted, result.Action); Assert.Equal("1", result.Version); Assert.Same(definition, Assert.Single(administration.CreatedDefinitions)); - Assert.Equal(("field-tools", "1"), Assert.Single(administration.Promotions)); + Assert.Empty(administration.Promotions); } [Fact] @@ -168,6 +168,65 @@ public async Task ReconcileAsync_RejectsForeignDefaultBeforePromotingReusableVer 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() { From 5954b52e094051489d1a587f53189b7aa8b54037 Mon Sep 17 00:00:00 2001 From: tommasodotnet Date: Wed, 2 Sep 2026 13:06:09 +0200 Subject: [PATCH 17/30] Retry Toolbox tool discovery Poll the MCP tools list within a bounded timeout to account for Foundry provisioning consistency. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../FoundryHostedAgentDeploymentTests.cs | 42 +++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs index d5654bfca44..b5ac651424e 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs @@ -462,19 +462,37 @@ await SendMcpRequestAsync( initialize.SessionId, """{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}""", cancellationToken); - var tools = await SendMcpRequestAsync( - client, - endpoint, - accessToken.Token, - initialize.SessionId, - """{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}""", - cancellationToken); + using var discoveryCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + discoveryCancellation.CancelAfter(TimeSpan.FromMinutes(2)); + try + { + for (var requestId = 2; ; requestId++) + { + var tools = await SendMcpRequestAsync( + client, + endpoint, + accessToken.Token, + initialize.SessionId, + $$$"""{"jsonrpc":"2.0","id":{{{requestId}}},"method":"tools/list","params":{}}""", + discoveryCancellation.Token); + var toolNames = tools.Result.GetProperty("tools") + .EnumerateArray() + .Select(tool => tool.GetProperty("name").GetString() + ?? throw new InvalidOperationException("A discovered MCP tool did not have a name.")) + .ToArray(); + if (toolNames.Length > 0) + { + return toolNames; + } - return tools.Result.GetProperty("tools") - .EnumerateArray() - .Select(tool => tool.GetProperty("name").GetString() - ?? throw new InvalidOperationException("A discovered MCP tool did not have a name.")) - .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 tool discovery remained empty for two minutes."); + } } private static async Task SendMcpRequestAsync( From 93975b4dac3b10915f6a369f9d7db14a0060114f Mon Sep 17 00:00:00 2001 From: tommasodotnet Date: Wed, 2 Sep 2026 13:27:31 +0200 Subject: [PATCH 18/30] Complete Toolbox reconciliation safeguards Verify reused defaults, suppress Search provisioning in existing mode, and send the negotiated MCP protocol version. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../FoundryToolboxBuilderExtensions.cs | 26 ++++++++++++-- .../Toolbox/FoundryToolboxReconciler.cs | 27 ++++++++++++++ .../Toolbox/FoundryToolboxResource.cs | 7 ++++ .../FoundryHostedAgentDeploymentTests.cs | 8 +++++ .../FoundryToolboxReconcilerTests.cs | 28 +++++++++++++++ .../ToolboxTests.cs | 35 +++++++++++++++++++ 6 files changed, 128 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs index eb99b850cba..0bb7cd98eec 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs @@ -84,7 +84,7 @@ public static IResourceBuilder RunAsExisting( if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode) { - builder.WithAnnotation(new FoundryToolboxExistingResourceAnnotation()); + ConfigureAsExisting(builder); } return builder; @@ -109,7 +109,7 @@ public static IResourceBuilder PublishAsExisting( if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode) { - builder.WithAnnotation(new FoundryToolboxExistingResourceAnnotation()); + ConfigureAsExisting(builder); } return builder; @@ -132,7 +132,9 @@ public static IResourceBuilder AsExisting( { ArgumentNullException.ThrowIfNull(builder); - return builder.WithAnnotation(new FoundryToolboxExistingResourceAnnotation()); + ConfigureAsExisting(builder); + + return builder; } /// @@ -290,6 +292,11 @@ public static IResourceBuilder WithAISearchTool( ArgumentNullException.ThrowIfNull(search); ArgumentException.ThrowIfNullOrWhiteSpace(indexName); + if (builder.Resource.IsExisting) + { + return builder; + } + var projectBuilder = builder.ApplicationBuilder.CreateResourceBuilder(builder.Resource.Parent); var connectionName = CreateSearchConnectionName( builder.Resource.Parent.Name, @@ -317,6 +324,19 @@ private static string CreateSearchConnectionName( 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, diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReconciler.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReconciler.cs index 58f66c1a44b..fad1300aab0 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReconciler.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReconciler.cs @@ -325,6 +325,10 @@ await PromoteOwnedVersionAsync( out var existingHash) && string.Equals(existingHash, definition.ConfigurationHash, StringComparison.Ordinal)) { + await VerifyReusedDefaultAsync( + definition, + existing.DefaultVersion, + cancellationToken).ConfigureAwait(false); return new(existing.DefaultVersion, FoundryToolboxReconcileAction.Reused); } @@ -363,6 +367,29 @@ await PromoteOwnedVersionAsync( 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, diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs index 21059ea5cf9..c5995fda25d 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs @@ -184,6 +184,11 @@ 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( @@ -193,6 +198,8 @@ internal void AddTool(FoundryToolboxToolDefinition tool) _tools.Add(tool); } + internal void ClearTools() => _tools.Clear(); + internal async Task CreateDeploymentDefinitionAsync( CancellationToken cancellationToken) { diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs index b5ac651424e..1510d0d0b6f 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs @@ -446,6 +446,7 @@ private static async Task> ListToolboxToolsAsync( 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"}}} """, @@ -460,6 +461,7 @@ await SendMcpRequestAsync( endpoint, accessToken.Token, initialize.SessionId, + negotiatedProtocol, """{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}""", cancellationToken); using var discoveryCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); @@ -473,6 +475,7 @@ await SendMcpRequestAsync( endpoint, accessToken.Token, initialize.SessionId, + negotiatedProtocol, $$$"""{"jsonrpc":"2.0","id":{{{requestId}}},"method":"tools/list","params":{}}""", discoveryCancellation.Token); var toolNames = tools.Result.GetProperty("tools") @@ -500,6 +503,7 @@ private static async Task SendMcpRequestAsync( Uri endpoint, string accessToken, string? sessionId, + string? protocolVersion, string payload, CancellationToken cancellationToken) { @@ -512,6 +516,10 @@ private static async Task SendMcpRequestAsync( { 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) diff --git a/tests/Aspire.Hosting.Foundry.Tests/FoundryToolboxReconcilerTests.cs b/tests/Aspire.Hosting.Foundry.Tests/FoundryToolboxReconcilerTests.cs index 3441df4a8bb..2f0b9705530 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/FoundryToolboxReconcilerTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/FoundryToolboxReconcilerTests.cs @@ -43,6 +43,34 @@ public async Task ReconcileAsync_ReusesMatchingAspireManagedVersion() 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() { diff --git a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs index 82b7068cbe4..8dabe01f7a0 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs @@ -86,6 +86,41 @@ public void AsExisting_UsesExistingToolboxInBothModes(DistributedApplicationOper 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() { From db5dd827b15017115b793ee3944656330775b999 Mon Sep 17 00:00:00 2001 From: tommasodotnet Date: Wed, 2 Sep 2026 13:51:14 +0200 Subject: [PATCH 19/30] Fix Toolbox consumer lifecycle and access Grant consumers Foundry User access, isolate Toolbox readiness from parent provisioning, and require Search discovery in deployment coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting.Foundry/FoundryResource.cs | 2 ++ .../HostedAgent/AzureHostedAgentResource.cs | 5 ++--- .../HostedAgent/HostedAgentBuilderExtension.cs | 8 ++++---- src/Aspire.Hosting.Foundry/README.md | 5 +++++ .../Toolbox/FoundryToolboxBuilderExtensions.cs | 12 +++++++++++- .../Toolbox/FoundryToolboxResource.cs | 2 +- .../FoundryHostedAgentDeploymentTests.cs | 7 ++++--- .../HostedAgentExtensionTests.cs | 10 +++++----- tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs | 13 +++++++++++++ 9 files changed, 47 insertions(+), 17 deletions(-) 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..803a3c1b8ec 100644 --- a/src/Aspire.Hosting.Foundry/HostedAgent/AzureHostedAgentResource.cs +++ b/src/Aspire.Hosting.Foundry/HostedAgent/AzureHostedAgentResource.cs @@ -25,10 +25,9 @@ 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 "Foundry 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"; /// @@ -258,7 +257,7 @@ private async Task AssignFoundryRoleToAgentIdentityAsync( 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); 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/README.md b/src/Aspire.Hosting.Foundry/README.md index b6b3b30340b..471aad63200 100644 --- a/src/Aspire.Hosting.Foundry/README.md +++ b/src/Aspire.Hosting.Foundry/README.md @@ -205,6 +205,11 @@ await service.withReference(toolbox); await service.waitFor(toolbox); ``` +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 +account or project so Aspire can manage Toolbox versions. Deployed compute resources that reference +the Toolbox receive this role automatically on the owning Foundry account. + 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. diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs index 0bb7cd98eec..8958ea6c6ba 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs @@ -41,8 +41,18 @@ public static IResourceBuilder AddToolbox( 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.Parent, roles)); +#pragma warning restore ASPIREAZURE003 + return builder.ApplicationBuilder.AddResource(toolbox) - .WithIconName("Toolbox"); + .WithIconName("Toolbox") + .WithParentRelationship(builder); } /// diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs index c5995fda25d..7e3d87f1f1a 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs @@ -22,7 +22,7 @@ namespace Aspire.Hosting.Foundry; /// when the configuration changes. /// [AspireExport] -public sealed class FoundryToolboxResource : Resource, IResourceWithParent, IResourceWithConnectionString +public sealed class FoundryToolboxResource : Resource, IResourceWithConnectionString { internal const string DefaultApiVersion = "v1"; internal const string PreviewFeatureHeaderValue = "Toolboxes=V1Preview"; diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs index 1510d0d0b6f..60e58ceb16a 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs @@ -163,7 +163,7 @@ await auto.TypeAsync( resources.ProjectEndpoint, credential, cancellationToken); - Assert.NotEmpty(discoveredTools); + Assert.Contains("knowledge-base", discoveredTools); // The first pipeline banner remains visible, so clear it before waiting for the redeploy. await auto.TypeAsync("clear"); @@ -483,7 +483,7 @@ await SendMcpRequestAsync( .Select(tool => tool.GetProperty("name").GetString() ?? throw new InvalidOperationException("A discovered MCP tool did not have a name.")) .ToArray(); - if (toolNames.Length > 0) + if (toolNames.Contains("knowledge-base", StringComparer.Ordinal)) { return toolNames; } @@ -494,7 +494,8 @@ await SendMcpRequestAsync( } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { - throw new TimeoutException("Foundry Toolbox tool discovery remained empty for two minutes."); + throw new TimeoutException( + "Foundry Toolbox did not discover the 'knowledge-base' Azure AI Search tool within two minutes."); } } 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 index 8dabe01f7a0..898f754140f 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs @@ -31,6 +31,19 @@ public void AddToolbox_CreatesProjectChildResource() 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.Parent, consumerRole.Target); + var role = Assert.Single(consumerRole.Roles); + Assert.Equal(FoundryResource.FoundryUserRoleDefinitionId, role.Id, ignoreCase: true); +#pragma warning restore ASPIREAZURE003 } [Fact] From 84f1280d9741d7edf43b92f03542671d0c2b73c4 Mon Sep 17 00:00:00 2001 From: tommasodotnet Date: Wed, 2 Sep 2026 14:14:28 +0200 Subject: [PATCH 20/30] Wait for Toolbox tool readiness Probe authenticated MCP discovery before publishing Running and correct Foundry User diagnostics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../HostedAgent/AzureHostedAgentResource.cs | 6 +- .../Toolbox/FoundryToolboxReadinessProbe.cs | 204 ++++++++++++++++++ .../Toolbox/FoundryToolboxResource.cs | 35 +++ .../Helpers/SequenceHttpMessageHandler.cs | 45 ++++ .../ToolboxTests.cs | 58 +++++ 5 files changed, 345 insertions(+), 3 deletions(-) create mode 100644 src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReadinessProbe.cs create mode 100644 tests/Aspire.Hosting.Foundry.Tests/Helpers/SequenceHttpMessageHandler.cs diff --git a/src/Aspire.Hosting.Foundry/HostedAgent/AzureHostedAgentResource.cs b/src/Aspire.Hosting.Foundry/HostedAgent/AzureHostedAgentResource.cs index 803a3c1b8ec..d8819e9009d 100644 --- a/src/Aspire.Hosting.Foundry/HostedAgent/AzureHostedAgentResource.cs +++ b/src/Aspire.Hosting.Foundry/HostedAgent/AzureHostedAgentResource.cs @@ -251,7 +251,7 @@ 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; } @@ -277,13 +277,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/Toolbox/FoundryToolboxReadinessProbe.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReadinessProbe.cs new file mode 100644 index 00000000000..952b7197761 --- /dev/null +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReadinessProbe.cs @@ -0,0 +1,204 @@ +// 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, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(endpoint); + ArgumentException.ThrowIfNullOrEmpty(accessToken); + ArgumentNullException.ThrowIfNull(requiredToolNames); + + 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; + do + { + var response = await SendRequestAsync( + endpoint, + accessToken, + initialize.SessionId, + negotiatedProtocol, + CreateToolsListPayload(requestId++, cursor), + discoveryCancellation.Token).ConfigureAwait(false); + 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)); + + if (requiredToolNames.Count == 0 + ? discoveredToolNames.Count > 0 + : requiredToolNames.All(discoveredToolNames.Contains)) + { + 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 expected = requiredToolNames.Count == 0 + ? "any tool" + : string.Join(", ", requiredToolNames.Order(StringComparer.Ordinal)); + 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) + { + 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); + response.EnsureSuccessStatusCode(); + var responsePayload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + 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 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); +} diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs index 7e3d87f1f1a..025ae21cf8f 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs @@ -6,6 +6,7 @@ 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; @@ -352,6 +353,12 @@ await notificationService.PublishUpdateAsync(this, snapshot => snapshot with 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, cancellationToken).ConfigureAwait(false); + if (IsExisting) { logger.LogInformation( @@ -399,6 +406,34 @@ await notificationService.PublishUpdateAsync(this, snapshot => snapshot with } } + private async Task WaitForToolDiscoveryAsync( + PipelineStepContext context, + CancellationToken cancellationToken) + { + var endpointValue = await UriExpression.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(); + using var client = new HttpClient(); + await new FoundryToolboxReadinessProbe(client).WaitForToolsAsync( + endpoint, + accessToken.Token, + requiredToolNames, + cancellationToken).ConfigureAwait(false); + } + private async Task WaitForProjectAndToolsAsync( ResourceNotificationService notificationService, DistributedApplicationModel model, 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/ToolboxTests.cs b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs index 898f754140f..61b5c168b99 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs @@ -6,6 +6,8 @@ #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; @@ -362,6 +364,56 @@ public async Task AzureAISearchToolDefinition_ConvertsToAzureAISearchTool() Assert.Contains("\"name\":\"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), + CreateJsonResponse("""{"jsonrpc":"2.0","id":2,"result":{"tools":[]}}"""), + 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"], + 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 McpToolDefinition_ConvertsWithLiteralEndpoint() { @@ -814,4 +866,10 @@ private static PipelineContext CreatePipelineContext(DistributedApplication app, 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") + }; } From dc43fa989fb09738ec07e30eecff30f134ed5f73 Mon Sep 17 00:00:00 2001 From: tommasodotnet Date: Wed, 2 Sep 2026 14:30:32 +0200 Subject: [PATCH 21/30] Harden Toolbox discovery retries Retry transient tool discovery failures, scope consumer access to the project, and complete Toolbox API documentation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../HostedAgent/AzureHostedAgentResource.cs | 3 -- src/Aspire.Hosting.Foundry/README.md | 4 +-- .../FoundryToolboxBuilderExtensions.cs | 14 +++++++- .../Toolbox/FoundryToolboxReadinessProbe.cs | 32 +++++++++++++++---- .../ToolboxTests.cs | 4 +-- 5 files changed, 42 insertions(+), 15 deletions(-) diff --git a/src/Aspire.Hosting.Foundry/HostedAgent/AzureHostedAgentResource.cs b/src/Aspire.Hosting.Foundry/HostedAgent/AzureHostedAgentResource.cs index d8819e9009d..f64a241bc00 100644 --- a/src/Aspire.Hosting.Foundry/HostedAgent/AzureHostedAgentResource.cs +++ b/src/Aspire.Hosting.Foundry/HostedAgent/AzureHostedAgentResource.cs @@ -25,9 +25,6 @@ namespace Aspire.Hosting.Foundry; /// public class AzureHostedAgentResource : Resource, IResourceWithEnvironment { - // The "Foundry 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 DefaultResponsesProtocolVersion = "2.0.0"; /// diff --git a/src/Aspire.Hosting.Foundry/README.md b/src/Aspire.Hosting.Foundry/README.md index 471aad63200..a89a454bc11 100644 --- a/src/Aspire.Hosting.Foundry/README.md +++ b/src/Aspire.Hosting.Foundry/README.md @@ -207,8 +207,8 @@ await service.waitFor(toolbox); 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 -account or project so Aspire can manage Toolbox versions. Deployed compute resources that reference -the Toolbox receive this role automatically on the owning Foundry account. +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 diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs index 8958ea6c6ba..d27837d32f9 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs @@ -28,6 +28,18 @@ public static class FoundryToolboxBuilderExtensions /// 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( @@ -47,7 +59,7 @@ public static IResourceBuilder AddToolbox( }; #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.Parent, roles)); + toolbox.Annotations.Add(new ReferenceRoleAssignmentAnnotation(builder.Resource, roles)); #pragma warning restore ASPIREAZURE003 return builder.ApplicationBuilder.AddResource(toolbox) diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReadinessProbe.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReadinessProbe.cs index 952b7197761..5a0e6858820 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReadinessProbe.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReadinessProbe.cs @@ -59,6 +59,7 @@ await SendRequestAsync( { var discoveredToolNames = new HashSet(StringComparer.Ordinal); string? cursor = null; + var retryDiscovery = false; do { var response = await SendRequestAsync( @@ -67,7 +68,14 @@ await SendRequestAsync( initialize.SessionId, negotiatedProtocol, CreateToolsListPayload(requestId++, cursor), - discoveryCancellation.Token).ConfigureAwait(false); + 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() @@ -82,9 +90,9 @@ await SendRequestAsync( } while (!string.IsNullOrEmpty(cursor)); - if (requiredToolNames.Count == 0 + if (!retryDiscovery && (requiredToolNames.Count == 0 ? discoveredToolNames.Count > 0 - : requiredToolNames.All(discoveredToolNames.Contains)) + : requiredToolNames.All(discoveredToolNames.Contains))) { return discoveredToolNames.ToArray(); } @@ -130,7 +138,8 @@ private async Task SendRequestAsync( string? sessionId, string? protocolVersion, string payload, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool retryInternalServerError = false) { using var request = new HttpRequestMessage(HttpMethod.Post, endpoint); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); @@ -152,11 +161,17 @@ private async Task SendRequestAsync( : (int?)null; using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - var responsePayload = await response.Content.ReadAsStringAsync(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); @@ -200,5 +215,8 @@ private async Task SendRequestAsync( return new(result, responseSessionId); } - private sealed record McpResponse(JsonElement Result, string? SessionId); + private sealed record McpResponse( + JsonElement Result, + string? SessionId, + bool IsRetryableFailure = false); } diff --git a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs index 61b5c168b99..57262e6cc6d 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs @@ -42,7 +42,7 @@ public void AddToolbox_CreatesProjectChildResource() #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.Parent, consumerRole.Target); + Assert.Same(project.Resource, consumerRole.Target); var role = Assert.Single(consumerRole.Roles); Assert.Equal(FoundryResource.FoundryUserRoleDefinitionId, role.Id, ignoreCase: true); #pragma warning restore ASPIREAZURE003 @@ -378,7 +378,7 @@ public async Task ReadinessProbe_RetriesAndFollowsToolsListPagination() using var handler = new SequenceHttpMessageHandler( initialize, new HttpResponseMessage(HttpStatusCode.Accepted), - CreateJsonResponse("""{"jsonrpc":"2.0","id":2,"result":{"tools":[]}}"""), + 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); From 09a4a7f0036637f1b7d4a3f895c1bed8aa3839a6 Mon Sep 17 00:00:00 2001 From: tommasodotnet Date: Wed, 2 Sep 2026 14:52:27 +0200 Subject: [PATCH 22/30] Fail Toolbox on terminal MCP dependencies Observe terminal dependency states so Toolbox readiness and waiting consumers fail promptly instead of hanging. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Toolbox/FoundryToolboxResource.cs | 22 ++++++++++++++++--- .../ToolboxTests.cs | 21 ++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs index 025ae21cf8f..8e7c797e2aa 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs @@ -464,14 +464,30 @@ await notificationService.WaitForResourceAsync( var mcpResourceWaits = GetMcpReferencedResources(model) .Where(resource => resource is IComputeResource and IResourceWithWaitSupport) - .Select(resource => notificationService.WaitForResourceAsync( - resource.Name, - KnownResourceStates.Running, + .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 diff --git a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs index 57262e6cc6d..d1b1b9fa593 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs @@ -860,6 +860,27 @@ await configurationAnnotation.Callback(new PipelineConfigurationContext 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(); From 99e82a05fef440febe9b4c050373b96b6dac4dec Mon Sep 17 00:00:00 2001 From: tommasodotnet Date: Wed, 2 Sep 2026 15:14:42 +0200 Subject: [PATCH 23/30] Validate Toolbox consumer version pins Verify an explicitly pinned immutable version still exists after managed reconciliation so deployment cannot report success with an unusable consumer endpoint. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Toolbox/FoundryToolboxReconciler.cs | 15 +++++++++++++++ .../Toolbox/FoundryToolboxResource.cs | 2 +- .../FoundryToolboxReconcilerTests.cs | 18 ++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReconciler.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReconciler.cs index fad1300aab0..ad209a6850d 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReconciler.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReconciler.cs @@ -302,6 +302,21 @@ private async Task ExecuteWithProjectReadinessRetryAsync( 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) diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs index 8e7c797e2aa..e092da5ed81 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs @@ -253,7 +253,7 @@ private async Task DeployAsync( var definition = await CreateDeploymentDefinitionAsync(cancellationToken).ConfigureAwait(false); var result = await new FoundryToolboxReconciler(administration) - .ReconcileAsync(definition, cancellationToken).ConfigureAwait(false); + .ReconcileAsync(definition, Version, cancellationToken).ConfigureAwait(false); DeployedVersion.Set(result.Version); return result; diff --git a/tests/Aspire.Hosting.Foundry.Tests/FoundryToolboxReconcilerTests.cs b/tests/Aspire.Hosting.Foundry.Tests/FoundryToolboxReconcilerTests.cs index 2f0b9705530..b25f6133233 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/FoundryToolboxReconcilerTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/FoundryToolboxReconcilerTests.cs @@ -43,6 +43,24 @@ public async Task ReconcileAsync_ReusesMatchingAspireManagedVersion() 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() { From 8638f81241b089733b89ff47282776917a9f6c83 Mon Sep 17 00:00:00 2001 From: tommasodotnet Date: Wed, 2 Sep 2026 15:36:16 +0200 Subject: [PATCH 24/30] Probe the reconciled Toolbox version Keep consumer version pins independent from readiness by probing the immutable version selected by reconciliation against the newly modeled tool names. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Toolbox/FoundryToolboxResource.cs | 15 ++++++++++++--- .../ToolboxTests.cs | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs index e092da5ed81..4a6eafe9e8b 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs @@ -173,7 +173,7 @@ public FoundryToolboxResource( /// Gets the Toolbox MCP endpoint URI expression. /// public ReferenceExpression UriExpression => Version is { Length: > 0 } version - ? ReferenceExpression.Create($"{Parent.Endpoint}/toolboxes/{Name}/versions/{version}/mcp?api-version={ApiVersion}") + ? GetVersionUriExpression(version) : ReferenceExpression.Create($"{Parent.Endpoint}/toolboxes/{Name}/mcp?api-version={ApiVersion}"); /// @@ -357,7 +357,10 @@ await notificationService.PublishUpdateAsync(this, snapshot => snapshot with { State = new("Waiting for Toolbox tools", KnownResourceStateStyles.Info) }).ConfigureAwait(false); - await WaitForToolDiscoveryAsync(context, cancellationToken).ConfigureAwait(false); + await WaitForToolDiscoveryAsync( + context, + result.Version, + cancellationToken).ConfigureAwait(false); if (IsExisting) { @@ -408,9 +411,11 @@ await notificationService.PublishUpdateAsync(this, snapshot => snapshot with private async Task WaitForToolDiscoveryAsync( PipelineStepContext context, + string reconciledVersion, CancellationToken cancellationToken) { - var endpointValue = await UriExpression.GetValueAsync(cancellationToken).ConfigureAwait(false); + var endpointValue = await GetVersionUriExpression(reconciledVersion) + .GetValueAsync(cancellationToken).ConfigureAwait(false); if (!Uri.TryCreate(endpointValue, UriKind.Absolute, out var endpoint) || endpoint.Scheme != Uri.UriSchemeHttps) { @@ -434,6 +439,10 @@ private async Task WaitForToolDiscoveryAsync( 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, diff --git a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs index d1b1b9fa593..640e637db45 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs @@ -208,6 +208,25 @@ public async Task WithReference_InjectsToolboxConnectionProperties() && 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() { From f217fefb601eaf27042c189be59f9f626ba81135 Mon Sep 17 00:00:00 2001 From: tommasodotnet Date: Wed, 2 Sep 2026 16:00:47 +0200 Subject: [PATCH 25/30] Complete Toolbox tool metadata Add Azure AI Search descriptions to the public API, canonical wire payload, configuration hash, documentation, and deployment coverage. Correct MCP server label documentation and tolerate Foundry's documented transient tools/list HTTP 500 response in the deployment test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting.Foundry/README.md | 8 +++-- .../FoundryToolboxBuilderExtensions.cs | 11 ++++-- .../Toolbox/FoundryToolboxOptions.cs | 3 +- .../Toolbox/FoundryToolboxToolDefinition.cs | 10 +++++- .../FoundryHostedAgentDeploymentTests.cs | 35 ++++++++++++++----- .../ToolboxTests.cs | 18 ++++++++-- 6 files changed, 67 insertions(+), 18 deletions(-) diff --git a/src/Aspire.Hosting.Foundry/README.md b/src/Aspire.Hosting.Foundry/README.md index a89a454bc11..5adaf6b4a8f 100644 --- a/src/Aspire.Hosting.Foundry/README.md +++ b/src/Aspire.Hosting.Foundry/README.md @@ -159,7 +159,7 @@ 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") + .WithAISearchTool("knowledge-base", search, "docs", "Search the internal knowledge base.") .WithMcpTool( "inventory", "https://inventory.example.com/mcp", @@ -192,7 +192,11 @@ await toolbox.withWebSearchTool({ name: "web-search", description: "Search the public web." }); -await toolbox.withAISearchTool("knowledge-base", search, "docs"); +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: { diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs index d27837d32f9..e37f6d64532 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxBuilderExtensions.cs @@ -300,6 +300,7 @@ internal static IResourceBuilder WithMcpToolForPolyglot( /// 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] @@ -307,12 +308,17 @@ public static IResourceBuilder WithAISearchTool( this IResourceBuilder builder, string name, IResourceBuilder search, - string indexName) + 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) { @@ -330,7 +336,8 @@ public static IResourceBuilder WithAISearchTool( name, search.Resource, connection.Resource, - indexName)); + indexName, + description)); return builder; } diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxOptions.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxOptions.cs index 16d0d53952b..36ba3033c59 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxOptions.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxOptions.cs @@ -23,7 +23,8 @@ internal sealed class FoundryToolboxOptions public sealed class FoundryToolboxMcpToolOptions { /// - /// Gets or sets the label sent to the MCP server. The Toolbox tool name is used when unset. + /// 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; } diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs index 0abbc2ed643..f2ecde2e849 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs @@ -361,7 +361,8 @@ internal FoundryToolboxAzureAISearchToolDefinition( string name, AzureSearchResource searchResource, AzureCognitiveServicesProjectConnectionResource connection, - string indexName) + string indexName, + string? description) : base(name) { ArgumentNullException.ThrowIfNull(searchResource); @@ -370,6 +371,7 @@ internal FoundryToolboxAzureAISearchToolDefinition( SearchResource = searchResource; Connection = connection; IndexName = indexName; + Description = description; } /// @@ -387,6 +389,8 @@ internal FoundryToolboxAzureAISearchToolDefinition( /// 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, @@ -421,6 +425,10 @@ internal override async ValueTask ResolveAsync(Cance property.WriteTo(writer); } writer.WriteString("name", Name); + if (Description is not null) + { + writer.WriteString("description", Description); + } writer.WriteEndObject(); } diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs index 60e58ceb16a..6b6b58721d8 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs @@ -122,7 +122,11 @@ public async Task DeployFoundryToolboxToAzure() Global = FoundryToolboxMcpGlobalApprovalMode.Always } }) - .WithAISearchTool("knowledge-base", search, "docs"); + .WithAISearchTool( + "knowledge-base", + search, + "docs", + "Search the internal knowledge base."); builder.Build().Run(); """); @@ -470,14 +474,27 @@ await SendMcpRequestAsync( { for (var requestId = 2; ; requestId++) { - var tools = await SendMcpRequestAsync( - client, - endpoint, - accessToken.Token, - initialize.SessionId, - negotiatedProtocol, - $$$"""{"jsonrpc":"2.0","id":{{{requestId}}},"method":"tools/list","params":{}}""", - discoveryCancellation.Token); + McpResponse tools; + try + { + tools = await SendMcpRequestAsync( + client, + endpoint, + accessToken.Token, + initialize.SessionId, + negotiatedProtocol, + $$$"""{"jsonrpc":"2.0","id":{{{requestId}}},"method":"tools/list","params":{}}""", + 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. + await Task.Delay(TimeSpan.FromSeconds(5), discoveryCancellation.Token); + continue; + } var toolNames = tools.Result.GetProperty("tools") .EnumerateArray() .Select(tool => tool.GetProperty("name").GetString() diff --git a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs index 640e637db45..24869b70073 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs @@ -148,7 +148,11 @@ public void WithToolMethods_AddToolDefinitions() .WithDescription("Tools for field technicians.") .WithWebSearchTool() .WithMcpTool("inventory", "https://inventory.example.com/mcp") - .WithAISearchTool("knowledge-base", search, "docs"); + .WithAISearchTool( + "knowledge-base", + search, + "docs", + "Search the internal knowledge base."); Assert.Collection( toolbox.Resource.Tools, @@ -169,6 +173,7 @@ public void WithToolMethods_AddToolDefinitions() 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); }); } @@ -363,7 +368,11 @@ public async Task AzureAISearchToolDefinition_ConvertsToAzureAISearchTool() var search = builder.AddAzureSearch("search"); var toolbox = project.AddToolbox("field-tools") - .WithAISearchTool("knowledge-base", search, "docs"); + .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. @@ -380,7 +389,10 @@ public async Task AzureAISearchToolDefinition_ConvertsToAzureAISearchTool() projectTool, ModelReaderWriterOptions.Json, AzureAIProjectsAgentsContext.Default); - Assert.Contains("\"name\":\"knowledge-base\"", json.ToString(), StringComparison.Ordinal); + Assert.Contains( + "\"name\":\"knowledge-base\",\"description\":\"Search the internal knowledge base.\"", + json.ToString(), + StringComparison.Ordinal); } [Fact] From bb1529cb9e54d8e22c27a59138f7923d705f3181 Mon Sep 17 00:00:00 2001 From: tommasodotnet Date: Wed, 2 Sep 2026 16:19:58 +0200 Subject: [PATCH 26/30] Follow Toolbox discovery pagination Accumulate every tools/list page in the live deployment assertion and restart discovery cleanly after documented transient server errors. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../FoundryHostedAgentDeploymentTests.cs | 91 +++++++++++++------ 1 file changed, 64 insertions(+), 27 deletions(-) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs index 6b6b58721d8..0cb7cb1d932 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs @@ -472,37 +472,51 @@ await SendMcpRequestAsync( discoveryCancellation.CancelAfter(TimeSpan.FromMinutes(2)); try { - for (var requestId = 2; ; requestId++) + var requestId = 2; + while (true) { - McpResponse tools; - try + var toolNames = new HashSet(StringComparer.Ordinal); + string? cursor = null; + var retryDiscovery = false; + do { - tools = await SendMcpRequestAsync( - client, - endpoint, - accessToken.Token, - initialize.SessionId, - negotiatedProtocol, - $$$"""{"jsonrpc":"2.0","id":{{{requestId}}},"method":"tools/list","params":{}}""", - 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. - await Task.Delay(TimeSpan.FromSeconds(5), discoveryCancellation.Token); - continue; + 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; } - var toolNames = tools.Result.GetProperty("tools") - .EnumerateArray() - .Select(tool => tool.GetProperty("name").GetString() - ?? throw new InvalidOperationException("A discovered MCP tool did not have a name.")) - .ToArray(); - if (toolNames.Contains("knowledge-base", StringComparer.Ordinal)) + while (!string.IsNullOrEmpty(cursor)); + + if (!retryDiscovery && toolNames.Contains("knowledge-base")) { - return toolNames; + return toolNames.ToArray(); } // Toolbox tool discovery is eventually consistent immediately after provisioning. @@ -516,6 +530,29 @@ await SendMcpRequestAsync( } } + 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, From 5e908f53e3c0cd7f7dccf31dbfef17607cb90af6 Mon Sep 17 00:00:00 2001 From: tommasodotnet Date: Wed, 2 Sep 2026 16:41:30 +0200 Subject: [PATCH 27/30] Clarify Toolbox Search index prerequisite Document that Azure AI Search Toolbox tools require a pre-existing populated index and do not provision index content. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting.Foundry/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Aspire.Hosting.Foundry/README.md b/src/Aspire.Hosting.Foundry/README.md index 5adaf6b4a8f..2b9ec0d6c3b 100644 --- a/src/Aspire.Hosting.Foundry/README.md +++ b/src/Aspire.Hosting.Foundry/README.md @@ -209,6 +209,10 @@ 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 From 6e4ac4d96c9e552500e7b662150eb59dc09cce7c Mon Sep 17 00:00:00 2001 From: tommasodotnet Date: Wed, 2 Sep 2026 17:03:48 +0200 Subject: [PATCH 28/30] Allow Search RBAC propagation Give the deployment test an isolated ten-minute budget for Azure Search role assignments to propagate before reporting a focused timeout. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../FoundryHostedAgentDeploymentTests.cs | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs index 0cb7cb1d932..aea770b62de 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs @@ -324,20 +324,36 @@ private static async Task EnsureSearchIndexExistsAsync( new SearchableField("content") } }; - for (var attempt = 0; ; attempt++) + using var rbacPropagationCancellation = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + rbacPropagationCancellation.CancelAfter(TimeSpan.FromMinutes(10)); + try { - try - { - await indexClient.CreateOrUpdateIndexAsync(index, cancellationToken: cancellationToken); - break; - } - catch (RequestFailedException ex) when (ex.Status == 403 && attempt < 11) + while (true) { - // Azure RBAC propagation is eventually consistent after the test principal receives - // Search Service Contributor on the newly provisioned service. - await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + 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) From 7205ea9fe3f48c82fba435fa1443007787f75b34 Mon Sep 17 00:00:00 2001 From: tommasodotnet Date: Wed, 2 Sep 2026 17:26:24 +0200 Subject: [PATCH 29/30] Require every Toolbox MCP server Keep mixed Toolboxes in readiness until built-in tools are present and each configured MCP server label contributes at least one discovered tool. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Toolbox/FoundryToolboxReadinessProbe.cs | 23 +++++++++++---- .../Toolbox/FoundryToolboxResource.cs | 5 ++++ .../ToolboxTests.cs | 29 +++++++++++++++++++ 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReadinessProbe.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReadinessProbe.cs index 5a0e6858820..9c94312b077 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReadinessProbe.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxReadinessProbe.cs @@ -19,11 +19,13 @@ 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); @@ -90,9 +92,16 @@ await SendRequestAsync( } while (!string.IsNullOrEmpty(cursor)); - if (!retryDiscovery && (requiredToolNames.Count == 0 - ? discoveredToolNames.Count > 0 - : requiredToolNames.All(discoveredToolNames.Contains))) + 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(); } @@ -103,9 +112,13 @@ await SendRequestAsync( } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { - var expected = requiredToolNames.Count == 0 + var expectedTools = requiredToolNames + .Concat(requiredMcpServerLabels.Select(label => $"{label}.*")) + .Order(StringComparer.Ordinal) + .ToArray(); + var expected = expectedTools.Length == 0 ? "any tool" - : string.Join(", ", requiredToolNames.Order(StringComparer.Ordinal)); + : string.Join(", ", expectedTools); throw new TimeoutException( $"Foundry Toolbox did not discover the required tools within {_timeout}: {expected}."); } diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs index 4a6eafe9e8b..4b25e61f725 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs @@ -431,11 +431,16 @@ private async Task WaitForToolDiscoveryAsync( .Where(tool => tool is not FoundryToolboxMcpToolDefinition) .Select(tool => tool.Name) .ToArray(); + var requiredMcpServerLabels = _tools + .OfType() + .Select(tool => tool.ServerLabel) + .ToArray(); using var client = new HttpClient(); await new FoundryToolboxReadinessProbe(client).WaitForToolsAsync( endpoint, accessToken.Token, requiredToolNames, + requiredMcpServerLabels, cancellationToken).ConfigureAwait(false); } diff --git a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs index 24869b70073..6d85ab6eab6 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs @@ -422,6 +422,7 @@ public async Task ReadinessProbe_RetriesAndFollowsToolsListPagination() new Uri("https://project.example.com/toolboxes/field-tools/mcp?api-version=v1"), "token", ["knowledge-base"], + requiredMcpServerLabels: [], CancellationToken.None); Assert.Equal(2, tools.Count); @@ -445,6 +446,34 @@ public async Task ReadinessProbe_RetriesAndFollowsToolsListPagination() 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() { From 7d4af08f546f69d70da410934903cdab54f0c998 Mon Sep 17 00:00:00 2001 From: tommasodotnet Date: Mon, 7 Sep 2026 09:06:01 +0200 Subject: [PATCH 30/30] Address Toolbox review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Project/ConnectionBuilderExtensions.cs | 3 +++ .../Toolbox/FoundryToolboxResource.cs | 5 ++--- .../Toolbox/FoundryToolboxToolDefinition.cs | 11 ---------- .../ToolboxTests.cs | 20 +++++++++---------- 4 files changed, 15 insertions(+), 24 deletions(-) diff --git a/src/Aspire.Hosting.Foundry/Project/ConnectionBuilderExtensions.cs b/src/Aspire.Hosting.Foundry/Project/ConnectionBuilderExtensions.cs index ba2d55e700f..eb3390a97c8 100644 --- a/src/Aspire.Hosting.Foundry/Project/ConnectionBuilderExtensions.cs +++ b/src/Aspire.Hosting.Foundry/Project/ConnectionBuilderExtensions.cs @@ -281,6 +281,9 @@ private static void AddSearchRoleAssignment( 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, diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs index 4b25e61f725..d92a6837367 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxResource.cs @@ -29,7 +29,6 @@ public sealed class FoundryToolboxResource : Resource, IResourceWithConnectionSt internal const string PreviewFeatureHeaderValue = "Toolboxes=V1Preview"; internal const string AuthorizationScopeValue = "https://ai.azure.com/.default"; - private const string BeforeStartStepName = "before-start"; private readonly List _tools = []; /// @@ -62,7 +61,7 @@ public FoundryToolboxResource( ? $"Validates existing Toolbox {Name} after the application starts." : $"Reconciles Toolbox {Name} after the application starts.", Action = DeployBeforeStartAsync, - RequiredBySteps = [BeforeStartStepName], + RequiredBySteps = [WellKnownPipelineSteps.BeforeStart], Resource = this, DependsOnSteps = [AzureEnvironmentResource.PrepareResourcesStepName] }); @@ -435,7 +434,7 @@ private async Task WaitForToolDiscoveryAsync( .OfType() .Select(tool => tool.ServerLabel) .ToArray(); - using var client = new HttpClient(); + var client = context.Services.GetRequiredService().CreateClient(); await new FoundryToolboxReadinessProbe(client).WaitForToolsAsync( endpoint, accessToken.Token, diff --git a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs index f2ecde2e849..b557d5394cf 100644 --- a/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs +++ b/src/Aspire.Hosting.Foundry/Toolbox/FoundryToolboxToolDefinition.cs @@ -26,17 +26,6 @@ private protected FoundryToolboxToolDefinition(string name) /// public string Name { get; } - /// - /// Resolves this tool definition into the SDK shape () used by the - /// Foundry data plane when creating a new toolbox version. - /// - /// Cancellation token. - internal async ValueTask ToProjectsAgentToolAsync(CancellationToken cancellationToken) - { - var resolved = await ResolveAsync(cancellationToken).ConfigureAwait(false); - return resolved.Tool; - } - internal abstract ValueTask ResolveAsync(CancellationToken cancellationToken); } diff --git a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs index 6d85ab6eab6..ba10b3dcba8 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/ToolboxTests.cs @@ -331,7 +331,7 @@ public async Task WebSearchToolDefinition_ConvertsToProjectsAgentTool() { var tool = new FoundryToolboxWebSearchToolDefinition("web-search"); - var projectTool = await tool.ToProjectsAgentToolAsync(CancellationToken.None); + var projectTool = (await tool.ResolveAsync(CancellationToken.None)).Tool; Assert.NotNull(projectTool); var json = ModelReaderWriter.Write( @@ -348,7 +348,7 @@ public async Task WebSearchToolDefinition_IncludesDescriptionWhenConfigured() "web-search", "Search the public web."); - var projectTool = await tool.ToProjectsAgentToolAsync(CancellationToken.None); + var projectTool = (await tool.ResolveAsync(CancellationToken.None)).Tool; var json = ModelReaderWriter.Write( projectTool, @@ -379,7 +379,7 @@ public async Task AzureAISearchToolDefinition_ConvertsToAzureAISearchTool() var def = Assert.IsType(toolbox.Resource.Tools[0]); def.Connection.Outputs["id"] = "/subscriptions/sub/resourceGroups/rg/connections/search"; - var projectTool = await def.ToProjectsAgentToolAsync(CancellationToken.None); + var projectTool = (await def.ResolveAsync(CancellationToken.None)).Tool; var aiSearch = Assert.IsType(projectTool); var index = Assert.Single(aiSearch.Options.Indexes); @@ -486,7 +486,7 @@ public async Task McpToolDefinition_ConvertsWithLiteralEndpoint() var def = Assert.IsType(toolbox.Resource.Tools[0]); - var projectTool = await def.ToProjectsAgentToolAsync(CancellationToken.None); + var projectTool = (await def.ResolveAsync(CancellationToken.None)).Tool; var json = ModelReaderWriter.Write( projectTool, @@ -519,7 +519,7 @@ public async Task McpToolDefinition_IncludesServerMetadataAndGlobalApproval() var definition = Assert.IsType( Assert.Single(toolbox.Resource.Tools)); - var projectTool = await definition.ToProjectsAgentToolAsync(CancellationToken.None); + var projectTool = (await definition.ResolveAsync(CancellationToken.None)).Tool; var json = ModelReaderWriter.Write( projectTool, @@ -559,7 +559,7 @@ public async Task McpToolDefinition_IncludesCanonicalCustomApproval() var definition = Assert.IsType( Assert.Single(toolbox.Resource.Tools)); - var projectTool = await definition.ToProjectsAgentToolAsync(CancellationToken.None); + var projectTool = (await definition.ResolveAsync(CancellationToken.None)).Tool; var json = ModelReaderWriter.Write( projectTool, @@ -640,7 +640,7 @@ public async Task McpToolDefinition_ThrowsWhenEndpointUnresolved() ReferenceExpression.Create($"{empty.Resource}")); await Assert.ThrowsAsync( - async () => await def.ToProjectsAgentToolAsync(CancellationToken.None)); + async () => await def.ResolveAsync(CancellationToken.None)); } [Fact] @@ -653,7 +653,7 @@ public async Task McpToolDefinition_ThrowsWhenEndpointIsNotHttps() ReferenceExpression.Create($"http://inventory.example.com/mcp")); var ex = await Assert.ThrowsAsync( - async () => await def.ToProjectsAgentToolAsync(CancellationToken.None)); + async () => await def.ResolveAsync(CancellationToken.None)); Assert.Contains("Foundry-reachable absolute HTTPS endpoint", ex.Message, StringComparison.Ordinal); } @@ -665,7 +665,7 @@ public async Task McpToolDefinition_ThrowsWhenEndpointIsLoopback() ReferenceExpression.Create($"https://localhost:7443/mcp")); var exception = await Assert.ThrowsAsync( - async () => await def.ToProjectsAgentToolAsync(CancellationToken.None)); + async () => await def.ResolveAsync(CancellationToken.None)); Assert.Contains("Foundry-reachable", exception.Message, StringComparison.Ordinal); } @@ -680,7 +680,7 @@ public async Task McpToolDefinition_ThrowsWhenEndpointIsNotPubliclyReachable(str ReferenceExpression.Create($"{endpoint}")); var exception = await Assert.ThrowsAsync( - async () => await def.ToProjectsAgentToolAsync(CancellationToken.None)); + async () => await def.ResolveAsync(CancellationToken.None)); Assert.Contains("Foundry-reachable", exception.Message, StringComparison.Ordinal); }