.NET: Hosted Agent Sample - Toolbox with various Auth (#5777) (#6018)

* .NET: Add Hosted-Toolbox-AuthPaths sample and auto-map /readiness with toolbox health gating (#5777)

Add a new hosted agent sample demonstrating five MCP tool authentication paths
(API key, agent MI, project MI, custom OAuth, literal token) via a Foundry Toolbox.

Package changes (Microsoft.Agents.AI.Foundry.Hosting):
- MapFoundryResponses now auto-maps GET /readiness via MapHealthChecks, idempotent
  across Tier 1/2 (AgentHost, already mapped) and Tier 3 (WebApplication, gap filled).
- AddFoundryResponses registers AddHealthChecks() so the pipeline is available.
- AddFoundryToolboxes registers FoundryToolboxHealthCheck on the /readiness aggregate,
  gating readiness on pre-registered toolbox startup outcome (per spec section 3.1).
- FoundryToolboxService now exposes StartupStatus and FailedToolboxNames properties.

New types:
- FoundryToolboxStartupStatus (public enum): Pending, Healthy, Failed, NoEndpoint.
- FoundryToolboxHealthCheck (internal IHealthCheck): adapts startup status to the
  AspNetCore HealthChecks pipeline with failed toolbox names in result data.

Tests:
- 3 new tests for /readiness auto-mapping (Tier 3 default, pre-mapped skip, idempotent).
- 4 new tests for FoundryToolboxHealthCheck (Pending, NoEndpoint, Failed, Healthy).
- 3 enhanced FoundryToolboxServiceTests with StartupStatus assertions.

* .NET: Align FoundryToolboxService with tools-integration-spec (#5777 Part A)

Bring Microsoft.Agents.AI.Foundry.Hosting's toolbox path into compliance with
tools-integration-spec.md sections 2-4, 6.3, and 9. Empirically validated
against tao-foundry-prj: the previous code (reading FOUNDRY_AGENT_TOOLSET_ENDPOINT,
which the platform never injects) silently registered zero tools in production.

Package changes (Microsoft.Agents.AI.Foundry.Hosting):

- FoundryToolboxService.StartAsync now derives the toolbox proxy base URL from
  the platform-injected FOUNDRY_PROJECT_ENDPOINT and constructs the per-toolbox
  URL as {FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{name}/mcp?api-version={ApiVersion}
  per spec sections 2-3. The legacy FOUNDRY_AGENT_TOOLSET_ENDPOINT env var is
  removed outright (preview package, no production consumers).
- FoundryToolboxOptions.ApiVersion default flipped to 'v1' to match spec example.
- FoundryToolboxBearerTokenHandler always sends the mandatory
  Foundry-Features: Toolboxes=V1Preview header per spec section 2, merging any
  additional flags supplied via the FOUNDRY_AGENT_TOOLSET_FEATURES env var.
- FoundryToolboxBearerTokenHandler token scope changed from
  https://cognitiveservices.azure.com/.default to https://ai.azure.com/.default
  per spec section 4.
- FoundryToolboxBearerTokenHandler propagates W3C trace context (traceparent,
  tracestate, baggage) from Activity.Current per spec section 6.3.

Sample changes:

- Hosted-Toolbox-AuthPaths and Hosted-Toolbox Program.cs, README.md, and
  .env.example corrected to describe the actual env-var contract
  (FOUNDRY_PROJECT_ENDPOINT auto-injected; AZURE_AI_PROJECT_ENDPOINT as the
  local-dev fallback). Removes the misleading 'auto-injected by Foundry runtime'
  claims for FOUNDRY_AGENT_TOOLSET_ENDPOINT.
- Hosted-Toolbox-AuthPaths/agent.manifest.yaml declares the toolbox and model
  dependencies under resources[] per the AgentManifest schema so azd ai agent
  init users get them provisioned automatically.

Tests:

- 4 new FoundryToolboxServiceTests covering env-var derivation, EndpointOverride
  precedence, trailing-slash normalization, and the existing NoEndpoint behavior
  under the new env var name.
- 4 new FoundryToolboxBearerTokenHandlerTests covering token scope, mandatory
  feature header always present, header merging with override, no duplicate
  mandatory flag, trace context propagation from Activity.Current, and no
  override of caller-set traceparent.
- New FoundryProjectEndpointEnvFixture xUnit collection definition serializes
  env-var-mutating tests across FoundryToolboxServiceTests and
  FoundryToolboxHealthCheckTests, preventing parallel-execution races.
- FoundryToolboxHealthCheckTests adjusted for the new env var name.

* .NET: Drop ACA prereq from Hosted-Toolbox-AuthPaths README (#5777 Part B)

Empirically verified that any Azure Cognitive Services MCP endpoint already in
the Foundry project (e.g., a Language service MCP) accepts Entra tokens and can
serve Paths 2 and 3 without deploying a separate Azure MCP Server to ACA.

README updates:
- Step 0 rewritten: 'Identify an Entra-authenticated MCP target in your project'
  instead of 'Deploy Azure MCP Server to Azure Container Apps' (the original
  azmcp-foundry-aca-mi setup is now optional, not required).
- Auth-paths matrix updated to describe AAD-based connections targeting a
  Cognitive Services MCP URL (e.g., Language service) instead of an ACA URL.
- Step 2 connections table updated: the Entra ID category is now a single 'AAD'
  authType. The original 'Agent Identity' vs 'Project Managed Identity' as
  selectable connection sub-types is NOT exposed via the ARM control plane
  today; the platform selects the calling principal contextually. Both
  connections in the walkthrough share the same shape and target.
- Added an explicit RBAC note: the agent identity AND project MI must hold the
  required role (typically Cognitive Services User) on the target resource;
  without it the MCP server returns HTTP 401 even though the connection wiring
  is correct.
- Toolbox tool entries renamed lang_entra_agent / lang_entra_project to
  match the new connection names.

Empirical validation supporting these changes is captured in the session
plan.md (Part B addendum).

* .NET: Document correct connection shape for Hosted-Toolbox-AuthPaths Paths 2/3 (#5777)

Updates the sample README with the verified connection shape and RBAC procedure
for Microsoft Entra agent-identity and project-managed-identity MCP authentication:

- Connection authType values: AgenticIdentityToken (agent identity) and
  ProjectManagedIdentity (project MI), both with category=RemoteTool.
- Top-level audience property required; for Cognitive Services targets the value
  is https://cognitiveservices.azure.com.
- Connections created via ARM REST (the Foundry portal wizard does not yet
  expose these authTypes).
- RBAC grants target the project's shared agent identity blueprint principal
  (project.properties.agentIdentity.agentIdentityId) for Path 2 and the
  project's system-assigned MI (project.identity.principalId) for Path 3.
- Troubleshooting table updated with the audience-mismatch symptom and the
  startup-cache behavior of FoundryToolboxService.

* .NET: Drop Path 3 (project MI) and align with new agent model in Hosted-Toolbox-AuthPaths (#5777)

Updates the sample to use only the new Foundry agent object model and removes
the project managed identity path:

- Auth-path matrix reduced to four paths: key, Entra agent identity, custom
  OAuth, inline authorization. Project managed identity is moved into a note
  describing when it applies (multiple agents sharing access) rather than as
  a documented sample path.
- RBAC instructions reference the agent's own instance_identity.principal_id
  from the agent ARM resource (new agent object model) instead of the
  project's shared agent identity blueprint (legacy model).
- Step 2 (connections) creates only the AgenticIdentityToken connection.
- Step 3 (toolbox tools) lists four tool entries instead of five.
- Sample prompts and troubleshooting table updated to match.

* .NET: Restore Path 3 (project MI) to Hosted-Toolbox-AuthPaths matrix (#5777)

The sample's purpose is to enumerate every authentication path a Foundry toolbox
can drive, not to pick one. Path 3 belongs alongside the other four with
explicit guidance for when each path is the right choice.

- Path 3 (project managed identity, authType=ProjectManagedIdentity) restored
  to the matrix with a 'When to pick this' column.
- Step 2 (connections) provisions both lang-mcp-agent-id and lang-mcp-project-mi
  via ARM REST.
- Step 3 (toolbox) lists five tool entries (one per path).
- RBAC instructions cover both the agent's instance identity (Path 2) and the
  project's system-assigned MI (Path 3).
- Sample prompts include all five paths.
- Troubleshooting table updated accordingly.

* .NET: Fix duplicate line in Hosted-Toolbox-AuthPaths README (#5777)

* .NET: Fix broken markdown link to ToolCallingApprovalHostedAgentFixture (#5777)

* .NET: Fix relative path depth in markdown link (#5777)

* .NET: Address Copilot review feedback for #5777

- FoundryToolboxHealthCheck description: rename FOUNDRY_AGENT_TOOLSET_ENDPOINT
  → FOUNDRY_PROJECT_ENDPOINT (stale reference; operator-facing in /readiness body).
- FoundryToolboxStartupStatus.NoEndpoint XML doc: same rename.
- ServiceCollectionExtensions XML docs: same rename + URL shape update.
- Foundry.Hosting.IntegrationTests.TestContainer: remove explicit
  app.MapGet('/readiness') — now redundant + would conflict with the
  auto-mapped readiness route from MapFoundryResponses.
- Hosted-Toolbox-AuthPaths agent.manifest.yaml: parameterize TOOLBOX_NAME via
  {{TOOLBOX_NAME}} template substitution and declare it under parameters with a
  default of 'auth-paths-toolbox' so the README's 'use any name' guidance
  actually works for hosted deployments.

* .NET: Address Copilot review round 2 — fallback env + dedup + naming (#5777)

- FoundryToolboxService.StartAsync: fall back to AZURE_AI_PROJECT_ENDPOINT when
  FOUNDRY_PROJECT_ENDPOINT is absent. Matches the local-dev convention used by
  the samples and resolves the doc/code mismatch flagged in review.
- FoundryToolboxHealthCheck description updated for the fallback.
- AddFoundryToolboxes: guard against duplicate health-check registration via an
  explicit name-uniqueness check on HealthCheckServiceOptions.Registrations.
  AddCheck<T>(name, ...) does not dedupe by name, so repeated AddFoundryToolboxes
  calls would have registered multiple instances.
- FoundryToolboxOptions.EndpointOverride doc: clarify URL becomes
  {EndpointOverride}/toolboxes/{name}/mcp (was missing /toolboxes/ segment).
- Hosted-Toolbox sample (Program.cs + README): switch FOUNDRY_TOOLBOX_NAME to
  TOOLBOX_NAME (the FOUNDRY_* prefix is reserved by the platform), default
  changed from 'my-toolset' to 'my-toolbox', terminology updated from 'Toolset'
  to 'Toolbox'.
- FoundryToolboxServiceTests: 2 test renames to reflect what they actually
  assert (StartupStatus + FailedToolboxNames, not URL shape directly).
- Tests adjusted to clear both env vars in NoEndpoint scenarios.

* .NET: Fix stale NoEndpoint XML doc and misleading test comment (#5777)

Update FoundryToolboxStartupStatus.NoEndpoint XML doc to mention both
FOUNDRY_PROJECT_ENDPOINT and AZURE_AI_PROJECT_ENDPOINT (the service
checks both since the fallback was added).

Fix test comment that claimed URL derivation validation when the test
only asserts on StartupStatus and FailedToolboxNames.

* Remove OAuth consent path from AuthPaths sample, keep four working auth paths

The interactive OAuth identity passthrough path needs a protocol gap closed in the
hosting package (the proprietary oauth_consent_request item is not representable
through the OpenAI/MEAI abstractions), so it is deferred to a separate spike branch.

This strips the OAuth path from the AuthPaths sample, the companion REPL client, the
agent manifest, and the docs, then renumbers the inline Authorization path so the
sample teaches four contiguous paths: API key via connection, Entra agent identity,
Entra project managed identity, and inline Authorization (anti-pattern).

Package code is unchanged; the consent infrastructure already present in main stays
as baseline. Both samples build with --warnaserror and all 246 hosting unit tests pass.

* .NET: Drop project MI auth path and dedicated client from Hosted-Toolbox-AuthPaths (#5777)

Live validation against tao-foundry-prj showed the ProjectManagedIdentity
path failing with an unresolved token audience 401, so the sample now ships
three working auth paths instead of four: connection key, agent managed
identity, and inline Authorization.

Changes:
- Remove the project managed identity path from the AuthPaths sample matrix,
  prerequisites, connections, toolbox table, prompts, Program.cs instructions
  and agent.manifest.yaml.
- Delete the near duplicate Hosted-Toolbox-AuthPaths-Client project and remove
  it from the solution. The README now drives the agent with the shared
  SimpleAgent REPL via AsAIAgent(agentEndpoint).
- Correct the troubleshooting note: the Foundry toolbox tools/list is all or
  nothing, so one bad source returns -32007, fails startup, and returns 424
  for every path. Add the allowed_tools caveat that names must match the
  upstream server.
- Mark the toolbox startup status and health check experimental under
  AgentsAIExperiments (MAAI001) instead of AIOpenAIResponses, and update the
  package NoWarn set accordingly.

* .NET: Address PR review nits for Hosted-Toolbox-AuthPaths (#5777)

- Remove duplicated NU1903 comment in Foundry.Hosting csproj.

- Fix stale 'four-tool' cross-links in Hosted-Toolbox and Hosted-McpTools READMEs to describe the three-path toolbox driven by the shared SimpleAgent REPL.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* .NET: Address toolbox startup-status review feedback (#5777)

- Rename FoundryToolboxStartupStatus.Failed to Unhealthy so it is the proper opposite of Healthy, and clarify the doc comment covers the partial-failure case.

- Raise the missing-endpoint toolbox log from Information to Warning, since enabling toolboxes is an explicit opt-in and a silently disabled toolbox warrants a higher-severity signal.

- Update unit tests and the AuthPaths README troubleshooting row accordingly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* .NET: Reword toolbox-wiring comment to avoid hosting-layer internals (#5777)

Address PR review feedback: explain how a Foundry Toolbox is attached using the public API (AddFoundryToolboxes vs the CreateHostedMcpToolbox marker) and observable behavior, instead of naming the internal AgentFrameworkResponseHandler type and FoundryToolboxService.Tools property.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Roger Barreto
2026-06-10 17:49:48 +01:00
committed by GitHub
Unverified
parent a5f4e0078e
commit dd29f9aa65
25 changed files with 1389 additions and 53 deletions
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
@@ -13,24 +14,32 @@ namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// An <see cref="DelegatingHandler"/> that:
/// <list type="bullet">
/// <item>Acquires a fresh Azure bearer token (scope: <c>https://cognitiveservices.azure.com/.default</c>) per request.</item>
/// <item>Injects the <c>Foundry-Features</c> header from <c>FOUNDRY_AGENT_TOOLSET_FEATURES</c> when non-empty.</item>
/// <item>Acquires a fresh Azure bearer token (scope: <c>https://ai.azure.com/.default</c>) per request, per <c>tools-integration-spec.md</c> §4.</item>
/// <item>Always injects the mandatory <c>Foundry-Features: Toolboxes=V1Preview</c> header per spec §2, merging any additional flags from <c>FOUNDRY_AGENT_TOOLSET_FEATURES</c>.</item>
/// <item>Propagates W3C trace context (<c>traceparent</c>, <c>tracestate</c>, <c>baggage</c>) from <see cref="Activity.Current"/> per spec §6.3.</item>
/// <item>Retries on HTTP 429, 500, 502, and 503 with exponential back-off (max 3 attempts, per spec §7).</item>
/// </list>
/// </summary>
internal sealed class FoundryToolboxBearerTokenHandler : DelegatingHandler
{
private const int MaxRetries = 3;
// Per tools-integration-spec.md §4, the container authenticates to the Foundry Toolbox
// proxy with a bearer token whose audience is https://ai.azure.com.
private static readonly TokenRequestContext s_tokenContext =
new(["https://cognitiveservices.azure.com/.default"]);
new(["https://ai.azure.com/.default"]);
// Per tools-integration-spec.md §2, every proxy request MUST include the
// Foundry-Features: Toolboxes=V1Preview opt-in header while the service is in preview.
private const string MandatoryFeatureFlag = "Toolboxes=V1Preview";
private readonly TokenCredential _credential;
private readonly string? _featuresHeaderValue;
private readonly string? _additionalFeaturesHeaderValue;
internal FoundryToolboxBearerTokenHandler(TokenCredential credential, string? featuresHeaderValue)
internal FoundryToolboxBearerTokenHandler(TokenCredential credential, string? additionalFeaturesHeaderValue)
{
this._credential = credential;
this._featuresHeaderValue = featuresHeaderValue;
this._additionalFeaturesHeaderValue = additionalFeaturesHeaderValue;
}
protected override async Task<HttpResponseMessage> SendAsync(
@@ -43,10 +52,9 @@ internal sealed class FoundryToolboxBearerTokenHandler : DelegatingHandler
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
if (!string.IsNullOrEmpty(this._featuresHeaderValue))
{
request.Headers.TryAddWithoutValidation("Foundry-Features", this._featuresHeaderValue);
}
request.Headers.TryAddWithoutValidation("Foundry-Features", BuildFeaturesHeaderValue(this._additionalFeaturesHeaderValue));
PropagateTraceContext(request);
// MaxRetries is the total number of attempts (not additional retries after the first).
for (int attempt = 0; attempt < MaxRetries; attempt++)
@@ -82,6 +90,75 @@ internal sealed class FoundryToolboxBearerTokenHandler : DelegatingHandler
throw new InvalidOperationException("Retry loop completed without returning a response.");
}
// Returns "Toolboxes=V1Preview" when no override is set, or
// "Toolboxes=V1Preview,<override-value>" when an override is set and doesn't already include it.
internal static string BuildFeaturesHeaderValue(string? additional)
{
if (string.IsNullOrWhiteSpace(additional))
{
return MandatoryFeatureFlag;
}
// Avoid duplicating the mandatory flag if the override happens to already include it
// (case-insensitive, ignore surrounding whitespace).
foreach (var part in additional!.Split(','))
{
if (string.Equals(part.Trim(), MandatoryFeatureFlag, StringComparison.OrdinalIgnoreCase))
{
return additional;
}
}
return $"{MandatoryFeatureFlag},{additional}";
}
// Per tools-integration-spec.md §6.3, propagate W3C trace context onto outbound requests.
// Skip headers already set on the message (callers / inner handlers may override).
private static void PropagateTraceContext(HttpRequestMessage request)
{
var activity = Activity.Current;
if (activity is null)
{
return;
}
if (!request.Headers.Contains("traceparent"))
{
var traceparent = activity.Id;
if (!string.IsNullOrEmpty(traceparent))
{
request.Headers.TryAddWithoutValidation("traceparent", traceparent);
}
}
var traceState = activity.TraceStateString;
if (!string.IsNullOrEmpty(traceState) && !request.Headers.Contains("tracestate"))
{
request.Headers.TryAddWithoutValidation("tracestate", traceState);
}
// Baggage is a comma-separated list of key=value pairs per the W3C Baggage spec.
if (!request.Headers.Contains("baggage"))
{
string? baggageHeader = null;
foreach (var pair in activity.Baggage)
{
if (pair.Value is null)
{
continue;
}
var entry = $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}";
baggageHeader = baggageHeader is null ? entry : $"{baggageHeader},{entry}";
}
if (baggageHeader is not null)
{
request.Headers.TryAddWithoutValidation("baggage", baggageHeader);
}
}
}
private static async Task<HttpRequestMessage> CloneRequestAsync(
HttpRequestMessage original,
CancellationToken cancellationToken)
@@ -0,0 +1,66 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Adapts <see cref="FoundryToolboxService.StartupStatus"/> to the AspNetCore
/// HealthChecks pipeline so the <c>GET /readiness</c> probe (mapped by
/// <see cref="FoundryHostingExtensions.MapFoundryResponses"/>) reflects whether
/// pre-registered toolbox connections are usable. Registered automatically by
/// <see cref="FoundryHostingExtensions.AddFoundryToolboxes(IServiceCollection, string[])"/>
/// and its overloads.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class FoundryToolboxHealthCheck : IHealthCheck
{
private readonly FoundryToolboxService _toolboxService;
public FoundryToolboxHealthCheck(FoundryToolboxService toolboxService)
{
ArgumentNullException.ThrowIfNull(toolboxService);
this._toolboxService = toolboxService;
}
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
switch (this._toolboxService.StartupStatus)
{
case FoundryToolboxStartupStatus.Healthy:
return Task.FromResult(HealthCheckResult.Healthy(
description: $"Foundry toolbox: {this._toolboxService.Tools.Count} tool(s) available."));
case FoundryToolboxStartupStatus.NoEndpoint:
return Task.FromResult(HealthCheckResult.Healthy(
description: "Foundry toolbox: neither FOUNDRY_PROJECT_ENDPOINT nor AZURE_AI_PROJECT_ENDPOINT is set; toolbox support disabled (local dev)."));
case FoundryToolboxStartupStatus.Pending:
return Task.FromResult(new HealthCheckResult(
status: context.Registration.FailureStatus,
description: "Foundry toolbox: startup has not completed yet."));
case FoundryToolboxStartupStatus.Unhealthy:
var data = new Dictionary<string, object>(StringComparer.Ordinal)
{
["failedToolboxes"] = this._toolboxService.FailedToolboxNames,
};
return Task.FromResult(new HealthCheckResult(
status: context.Registration.FailureStatus,
description: $"Foundry toolbox: {this._toolboxService.FailedToolboxNames.Count} pre-registered toolbox(es) failed to open at startup.",
data: data));
default:
return Task.FromResult(new HealthCheckResult(
status: context.Registration.FailureStatus,
description: $"Foundry toolbox: unknown startup status '{this._toolboxService.StartupStatus}'."));
}
}
}
@@ -16,14 +16,15 @@ public sealed class FoundryToolboxOptions
/// Gets the list of toolbox names to connect to at startup.
/// Each name corresponds to a toolbox registered in the Foundry project.
/// The platform proxy URL is constructed as:
/// <c>{FOUNDRY_AGENT_TOOLSET_ENDPOINT}/{toolboxName}/mcp?api-version={ApiVersion}</c>
/// <c>{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{toolboxName}/mcp?api-version={ApiVersion}</c>
/// per <c>tools-integration-spec.md</c> §2–§3.
/// </summary>
public IList<string> ToolboxNames { get; } = [];
/// <summary>
/// Gets or sets the Toolsets API version to use when constructing proxy URLs.
/// Gets or sets the Toolboxes API version to use when constructing proxy URLs.
/// </summary>
public string ApiVersion { get; set; } = "2025-05-01-preview";
public string ApiVersion { get; set; } = "v1";
/// <summary>
/// Gets or sets a value indicating whether per-request toolbox markers (referenced via
@@ -36,7 +37,9 @@ public sealed class FoundryToolboxOptions
public bool StrictMode { get; set; } = true;
/// <summary>
/// For testing only: overrides <c>FOUNDRY_AGENT_TOOLSET_ENDPOINT</c>.
/// For testing only: overrides the toolbox proxy base URL (skipping the
/// <c>FOUNDRY_PROJECT_ENDPOINT</c>-derived default). When set, the proxy URL
/// becomes <c>{EndpointOverride}/toolboxes/{toolboxName}/mcp?api-version={ApiVersion}</c>.
/// Not part of the public API.
/// </summary>
internal string? EndpointOverride { get; set; }
@@ -24,7 +24,13 @@ namespace Microsoft.Agents.AI.Foundry.Hosting;
/// </summary>
/// <remarks>
/// <para>
/// When <c>FOUNDRY_AGENT_TOOLSET_ENDPOINT</c> is absent the service starts without error and
/// The toolbox proxy base URL is derived from the platform-injected
/// <c>FOUNDRY_PROJECT_ENDPOINT</c> environment variable per <c>tools-integration-spec.md</c>
/// §2–§3. The per-toolbox proxy URL is constructed as
/// <c>{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{toolboxName}/mcp?api-version={ApiVersion}</c>.
/// </para>
/// <para>
/// When <c>FOUNDRY_PROJECT_ENDPOINT</c> is absent the service starts without error and
/// no tools are registered, keeping the container healthy per spec §2.
/// </para>
/// <para>
@@ -56,6 +62,24 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
/// </summary>
public IReadOnlyList<AITool> Tools { get; private set; } = [];
/// <summary>
/// Gets the startup status of the service. Reflects the outcome of pre-registered
/// toolbox connections opened in <see cref="StartAsync"/>; lazy-opens triggered by
/// per-request markers do not change this value.
/// </summary>
/// <remarks>
/// Consumed by <see cref="FoundryToolboxHealthCheck"/> to gate the
/// <c>GET /readiness</c> probe so the Foundry hosted runtime does not start routing
/// traffic to a container whose pre-registered toolbox failed to open at startup.
/// </remarks>
public FoundryToolboxStartupStatus StartupStatus { get; private set; } = FoundryToolboxStartupStatus.Pending;
/// <summary>
/// Gets the names of pre-registered toolboxes that failed to open during
/// <see cref="StartAsync"/>. Empty when startup was successful or has not run yet.
/// </summary>
public IReadOnlyList<string> FailedToolboxNames { get; private set; } = [];
/// <summary>
/// Initializes a new instance of <see cref="FoundryToolboxService"/>.
/// </summary>
@@ -75,16 +99,24 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
/// <inheritdoc/>
public async Task StartAsync(CancellationToken cancellationToken)
{
this._resolvedEndpoint = this._options.EndpointOverride
?? Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT");
// Per tools-integration-spec.md §2-§3, the container derives the toolbox proxy base
// URL from the platform-injected FOUNDRY_PROJECT_ENDPOINT. The EndpointOverride
// option exists for tests; AZURE_AI_PROJECT_ENDPOINT is honored as a local-dev
// fallback to mirror the convention used by AF-repo samples.
var projectEndpoint = this._options.EndpointOverride
?? Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
?? Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT");
if (string.IsNullOrEmpty(this._resolvedEndpoint))
if (string.IsNullOrEmpty(projectEndpoint))
{
this._logger.LogInformation("FOUNDRY_AGENT_TOOLSET_ENDPOINT is not set; toolbox support is disabled.");
this._logger.LogWarning(
"Neither FOUNDRY_PROJECT_ENDPOINT nor AZURE_AI_PROJECT_ENDPOINT is set; toolbox support is disabled.");
this.Tools = [];
this.StartupStatus = FoundryToolboxStartupStatus.NoEndpoint;
return;
}
this._resolvedEndpoint = projectEndpoint.TrimEnd('/');
this._featuresHeader = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_FEATURES");
this._agentName = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_NAME") ?? "hosted-agent";
this._agentVersion = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_VERSION") ?? "1.0.0";
@@ -93,10 +125,12 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
{
this._logger.LogInformation("No pre-registered toolbox names configured.");
this.Tools = [];
this.StartupStatus = FoundryToolboxStartupStatus.Healthy;
return;
}
var allTools = new List<AITool>();
var failed = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var toolboxName in this._options.ToolboxNames)
@@ -121,10 +155,16 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
"Failed to connect to toolbox '{ToolboxName}'. Tools from this toolbox will not be available.",
toolboxName);
}
failed.Add(toolboxName);
}
}
this.Tools = allTools;
this.FailedToolboxNames = failed;
this.StartupStatus = failed.Count == 0
? FoundryToolboxStartupStatus.Healthy
: FoundryToolboxStartupStatus.Unhealthy;
}
/// <summary>
@@ -165,7 +205,7 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
if (string.IsNullOrEmpty(this._resolvedEndpoint))
{
throw new InvalidOperationException(
$"Cannot resolve toolbox '{toolboxName}': FOUNDRY_AGENT_TOOLSET_ENDPOINT is not set.");
$"Cannot resolve toolbox '{toolboxName}': FOUNDRY_PROJECT_ENDPOINT is not set.");
}
await this._lazyOpenLock.WaitAsync(cancellationToken).ConfigureAwait(false);
@@ -192,7 +232,7 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
string? version,
CancellationToken cancellationToken)
{
var proxyUrl = $"{this._resolvedEndpoint!.TrimEnd('/')}/{toolboxName}/mcp?api-version={this._options.ApiVersion}";
var proxyUrl = $"{this._resolvedEndpoint!}/toolboxes/{toolboxName}/mcp?api-version={this._options.ApiVersion}";
if (this._logger.IsEnabled(LogLevel.Information))
{
@@ -0,0 +1,46 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Outcome of <see cref="FoundryToolboxService"/> startup. Drives the
/// <c>foundry-toolbox</c> health-check that gates the <c>GET /readiness</c> probe so the
/// Foundry hosted runtime does not start routing traffic before pre-registered toolbox
/// connections are confirmed open (per <c>container-image-spec.md</c> §3.1).
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public enum FoundryToolboxStartupStatus
{
/// <summary>
/// <see cref="FoundryToolboxService.StartAsync"/> has not run yet. The health-check
/// reports <c>Unhealthy</c> in this state so the platform waits for startup to
/// complete before the first invocation.
/// </summary>
Pending = 0,
/// <summary>
/// Startup completed and either every pre-registered toolbox opened successfully or
/// no pre-registered toolboxes were configured. The health-check reports
/// <c>Healthy</c>.
/// </summary>
Healthy = 1,
/// <summary>
/// One or more pre-registered toolboxes failed to open during startup (including the
/// partial case where some opened and some did not). The health-check reports
/// <c>Unhealthy</c> and exposes the failed names in the <c>HealthCheckResult.Data</c>
/// dictionary so operators can diagnose the failure without parsing log output.
/// </summary>
Unhealthy = 2,
/// <summary>
/// Neither the <c>FOUNDRY_PROJECT_ENDPOINT</c> nor the <c>AZURE_AI_PROJECT_ENDPOINT</c>
/// environment variable is set. This is normal for local <c>dotnet run</c> flows and the
/// health-check reports <c>Healthy</c> so the container is still routable; toolbox tools
/// will simply not be available.
/// </summary>
NoEndpoint = 3,
}
@@ -13,7 +13,7 @@
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
<InjectSharedRedaction>true</InjectSharedRedaction>
<NoWarn>$(NoWarn);OPENAI001;MEAI001;NU1903</NoWarn> <!-- NU1903: Microsoft.Bcl.Memory 9.0.4 transitive vulnerability via Azure SDK; awaiting upstream fix -->
<NoWarn>$(NoWarn);OPENAI001;MEAI001;MAAI001;NU1903</NoWarn> <!-- NU1903: Microsoft.Bcl.Memory 9.0.4 transitive vulnerability via Azure SDK; awaiting upstream fix -->
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
@@ -7,10 +7,12 @@ using System.Runtime.CompilerServices;
using Azure.AI.AgentServer.Responses;
using Azure.Core;
using Azure.Identity;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry.Hosting;
@@ -49,6 +51,7 @@ public static class FoundryHostingExtensions
{
ArgumentNullException.ThrowIfNull(services);
services.AddResponsesServer();
services.AddHealthChecks();
services.TryAddSingleton<AgentSessionStore>(_ => FileSystemAgentSessionStore.CreateDefault());
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
return services;
@@ -84,6 +87,7 @@ public static class FoundryHostingExtensions
ArgumentNullException.ThrowIfNull(agent);
services.AddResponsesServer();
services.AddHealthChecks();
agentSessionStore ??= FileSystemAgentSessionStore.CreateDefault();
if (!string.IsNullOrWhiteSpace(agent.Name))
@@ -109,10 +113,10 @@ public static class FoundryHostingExtensions
/// <para>
/// Each string in <paramref name="toolboxNames"/> is a toolbox name registered in the Foundry
/// project. The proxy URL per toolbox is constructed as:
/// <c>{FOUNDRY_AGENT_TOOLSET_ENDPOINT}/{toolboxName}/mcp?api-version=2025-05-01-preview</c>
/// <c>{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{toolboxName}/mcp?api-version=v1</c>
/// </para>
/// <para>
/// When <c>FOUNDRY_AGENT_TOOLSET_ENDPOINT</c> is absent, startup succeeds without error and
/// When <c>FOUNDRY_PROJECT_ENDPOINT</c> is absent, startup succeeds without error and
/// no tools are loaded (the container remains healthy per spec §2).
/// </para>
/// <para>
@@ -167,12 +171,61 @@ public static class FoundryHostingExtensions
// multiple times will not invoke StartAsync twice on the same singleton.
services.AddHostedService(sp => sp.GetRequiredService<FoundryToolboxService>());
// Register the toolbox health check on the same /readiness pipeline that
// MapFoundryResponses maps. This gates the Foundry hosted runtime's readiness
// probe (per container-image-spec.md §3.1) on the outcome of the pre-registered
// toolbox connections opened in FoundryToolboxService.StartAsync.
// AddCheck<T>(name, ...) does NOT dedupe by name, so guard against duplicate
// registration when AddFoundryToolboxes is called multiple times.
const string HealthCheckName = "foundry-toolbox";
services.AddHealthChecks();
services.Configure<HealthCheckServiceOptions>(opts =>
{
foreach (var existing in opts.Registrations)
{
if (string.Equals(existing.Name, HealthCheckName, StringComparison.Ordinal))
{
return;
}
}
opts.Registrations.Add(new HealthCheckRegistration(
name: HealthCheckName,
factory: sp => ActivatorUtilities.CreateInstance<FoundryToolboxHealthCheck>(sp),
failureStatus: HealthStatus.Unhealthy,
tags: ["foundry", "toolbox", "readiness"]));
});
return services;
}
/// <summary>
/// Maps the Responses API routes for the agent-framework handler to the endpoint routing pipeline.
/// </summary>
/// <remarks>
/// <para>
/// Also maps the Foundry-required <c>GET /readiness</c> health probe to
/// <see cref="HealthCheckEndpointRouteBuilderExtensions.MapHealthChecks(IEndpointRouteBuilder, string)"/>
/// when no <c>/readiness</c> route is already registered. This makes the package
/// spec-compliant in the Foundry hosted runtime (which probes <c>/readiness</c>
/// before accepting any invocation per <c>container-image-spec.md</c> §2; without
/// it every request fails with HTTP 424 <c>session_not_ready</c>) regardless of the
/// host spine the developer chose:
/// </para>
/// <list type="bullet">
/// <item><description><b>Tier 1/2</b> (<c>AgentHost.CreateBuilder</c>) — the Core SDK
/// already maps <c>/readiness</c>. The duplicate-route guard below skips
/// re-mapping it.</description></item>
/// <item><description><b>Tier 3</b> (<c>WebApplication.CreateBuilder</c> +
/// <c>AddFoundryResponses</c> + <c>MapFoundryResponses</c>) — the Core SDK
/// does NOT map it. This call covers the gap automatically.</description></item>
/// </list>
/// <para>
/// Developers can still opt out by registering their own <c>/readiness</c> route
/// before calling <c>MapFoundryResponses</c>; the existing route is detected and
/// preserved.
/// </para>
/// </remarks>
/// <param name="endpoints">The endpoint route builder.</param>
/// <param name="prefix">Optional route prefix (e.g., "/openai/v1"). Default: empty (routes at /responses).</param>
/// <returns>The endpoint route builder for chaining.</returns>
@@ -180,9 +233,37 @@ public static class FoundryHostingExtensions
{
ArgumentNullException.ThrowIfNull(endpoints);
endpoints.MapResponsesServer(prefix);
MapReadinessIfMissing(endpoints);
return endpoints;
}
/// <summary>
/// Maps <c>GET /readiness</c> to the AspNetCore HealthChecks pipeline only when no
/// route already serves that path. The duplicate guard scans
/// <see cref="EndpointDataSource"/> entries by route pattern, which catches both the
/// SDK-mapped <c>MapHealthChecks("/readiness")</c> path used by
/// <c>AgentHostBuilder</c> and any user-registered <c>app.MapGet("/readiness", ...)</c>
/// route. Idempotent across multiple <c>MapFoundryResponses</c> invocations.
/// </summary>
private static void MapReadinessIfMissing(IEndpointRouteBuilder endpoints)
{
const string ReadinessPath = "/readiness";
foreach (var dataSource in endpoints.DataSources)
{
foreach (var endpoint in dataSource.Endpoints)
{
if (endpoint is RouteEndpoint route &&
string.Equals(route.RoutePattern.RawText, ReadinessPath, StringComparison.OrdinalIgnoreCase))
{
return;
}
}
}
endpoints.MapHealthChecks(ReadinessPath);
}
/// <summary>
/// The ActivitySource name for the Responses hosting pipeline.
/// </summary>