mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
* .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:
committed by
GitHub
Unverified
parent
a5f4e0078e
commit
dd29f9aa65
@@ -344,6 +344,9 @@
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/Hosted-Toolbox-AuthPaths.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/HostedToolboxMcpSkills.csproj" />
|
||||
</Folder>
|
||||
|
||||
@@ -109,3 +109,9 @@ For end-to-end hosted agent deployment guidance, see the [official deployment gu
|
||||
## NuGet package users
|
||||
|
||||
Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedMcpTools.csproj` for the `PackageReference` alternative.
|
||||
|
||||
## Related samples
|
||||
|
||||
- [`Hosted-Toolbox/`](../Hosted-Toolbox/) — connects to a single Foundry Toolbox via the AF Foundry hosting bridge (`AddFoundryToolboxes` + `FoundryAITool.CreateHostedMcpToolbox`).
|
||||
- [`Hosted-Toolbox-AuthPaths/`](../Hosted-Toolbox-AuthPaths/) — same hosting bones as `Hosted-Toolbox/`, but the toolbox bundles three MCP tools each authenticated differently (key, Entra agent identity, inline `Authorization`), driven by the shared `Using-Samples/SimpleAgent/` REPL.
|
||||
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
# Azure AI Foundry project endpoint (auto-injected in hosted containers).
|
||||
AZURE_AI_PROJECT_ENDPOINT=https://<your-foundry-account>.services.ai.azure.com/api/projects/<your-project>
|
||||
|
||||
# Model deployment name. Must exist in the Foundry project above.
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
|
||||
# Name of the Foundry Toolbox you provisioned in the portal (see README.md).
|
||||
TOOLBOX_NAME=auth-paths-toolbox
|
||||
|
||||
# Agent name advertised over the wire. Must be unique if running side-by-side with
|
||||
# other Hosted-* samples (e.g. Hosted-Toolbox), otherwise the REPL client cannot
|
||||
# disambiguate which agent to chat with.
|
||||
AGENT_NAME=hosted-toolbox-auth-paths-agent
|
||||
|
||||
# Application Insights connection string (auto-injected in hosted containers; optional locally).
|
||||
# APPLICATIONINSIGHTS_CONNECTION_STRING=InstrumentationKey=...
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
# Use the official .NET 10.0 ASP.NET runtime as a parent image
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet restore
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
|
||||
# Final stage
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedToolboxAuthPaths.dll"]
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# Dockerfile for contributors building from the agent-framework repository source.
|
||||
#
|
||||
# This project uses ProjectReference to the local source, which means a standard
|
||||
# multi-stage Docker build cannot resolve dependencies outside this folder.
|
||||
# Pre-publish the app targeting the container runtime and copy the output:
|
||||
#
|
||||
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
# docker build -f Dockerfile.contributor -t hosted-toolbox-auth-paths .
|
||||
# docker run --rm -p 8088:8088 \
|
||||
# -e AGENT_NAME=hosted-toolbox-auth-paths-agent \
|
||||
# -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
|
||||
# --env-file .env hosted-toolbox-auth-paths
|
||||
#
|
||||
# For end-users consuming the NuGet package (not ProjectReference), use the standard
|
||||
# Dockerfile which performs a full dotnet restore + publish inside the container.
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
||||
WORKDIR /app
|
||||
COPY out/ .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedToolboxAuthPaths.dll"]
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>HostedToolboxAuthPaths</RootNamespace>
|
||||
<AssemblyName>HostedToolboxAuthPaths</AssemblyName>
|
||||
<NoWarn>$(NoWarn);OPENAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For contributors: uses ProjectReference to build against local source -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
</Project>
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Foundry Toolbox Auth Paths Agent — A hosted agent backed by a single Foundry Toolbox
|
||||
// that bundles MCP tools using THREE different authentication paths.
|
||||
//
|
||||
// This sample demonstrates the same hosting bones as Hosted-Toolbox/, but the toolbox
|
||||
// (provisioned by the user out-of-band) contains three MCP tool entries each authenticated
|
||||
// differently. The agent code itself is agnostic to authentication — the educational
|
||||
// surface lives in the toolbox configuration in the Foundry portal and in this sample's
|
||||
// README.md.
|
||||
//
|
||||
// Required environment variables:
|
||||
// AZURE_AI_PROJECT_ENDPOINT (local-dev) OR FOUNDRY_PROJECT_ENDPOINT (hosted runtime)
|
||||
// - Azure AI Foundry project endpoint. The Foundry hosted
|
||||
// runtime auto-injects FOUNDRY_PROJECT_ENDPOINT; locally
|
||||
// set AZURE_AI_PROJECT_ENDPOINT (the AF-repo convention).
|
||||
// TOOLBOX_NAME - Name of the Foundry Toolbox to load
|
||||
// (default: auth-paths-toolbox)
|
||||
//
|
||||
// Optional:
|
||||
// AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-4o)
|
||||
// AGENT_NAME - Defaults to "hosted-toolbox-auth-paths-agent".
|
||||
//
|
||||
// The Foundry.Hosting package builds the toolbox proxy URL from FOUNDRY_PROJECT_ENDPOINT
|
||||
// per tools-integration-spec.md §2–§3, so the sample does not need to plumb any
|
||||
// toolbox-specific URL env var.
|
||||
//
|
||||
// NOTE: All FOUNDRY_* and AGENT_* env-var prefixes (other than the platform-injected ones
|
||||
// listed above) are reserved by the Foundry container platform and rejected by the
|
||||
// agent-create API. Use TOOLBOX_NAME, not FOUNDRY_TOOLBOX_NAME, for sample-owned config.
|
||||
|
||||
#pragma warning disable OPENAI001 // FoundryAITool.CreateHostedMcpToolbox is experimental
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
// Project endpoint resolution order:
|
||||
// 1. FOUNDRY_PROJECT_ENDPOINT — auto-injected by the Foundry hosted runtime.
|
||||
// 2. AZURE_AI_PROJECT_ENDPOINT — the convention developers set locally for `dotnet run`.
|
||||
// When deployed, only (1) is available; the AF-repo sample convention to set (2) at
|
||||
// deploy time fails silently because the platform reserves all FOUNDRY_* env-var names
|
||||
// and rejects them at agent-create time. Read both, prefer the platform-injected one.
|
||||
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException(
|
||||
"Neither FOUNDRY_PROJECT_ENDPOINT (platform-injected in hosted runtime) " +
|
||||
"nor AZURE_AI_PROJECT_ENDPOINT (local-dev convention) is set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
|
||||
string toolboxName = Environment.GetEnvironmentVariable("TOOLBOX_NAME") ?? "auth-paths-toolbox";
|
||||
string agentName = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-toolbox-auth-paths-agent";
|
||||
|
||||
TokenCredential credential = new ChainedTokenCredential(
|
||||
new DevTemporaryTokenCredential(),
|
||||
new DefaultAzureCredential());
|
||||
|
||||
// Notes on toolbox wiring — there are two ways to attach a Foundry Toolbox to an agent:
|
||||
// - Server-side "baked-in" (what this sample uses): calling AddFoundryToolboxes(name)
|
||||
// below registers the toolbox with the Foundry.Hosting layer, which resolves that
|
||||
// toolbox's MCP tools once at startup and automatically makes them available to the
|
||||
// agent on every request. The agent code does nothing per request.
|
||||
// - Per-request / caller-driven (NOT used here): a client can attach a toolbox for a
|
||||
// single call by placing a FoundryAITool.CreateHostedMcpToolbox(name) marker in the
|
||||
// request body's tool list.
|
||||
// Because this sample bakes the toolbox in on the server, it uses AddFoundryToolboxes and
|
||||
// does NOT put the CreateHostedMcpToolbox marker in the agent's `tools:` array.
|
||||
AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
|
||||
.AsAIAgent(
|
||||
model: deploymentName,
|
||||
instructions: """
|
||||
You are a helpful assistant with access to several tools, each provided by a different
|
||||
upstream service authenticated through a distinct mechanism (API key, agent managed
|
||||
identity, and a literal token
|
||||
shipped with the tool definition). Pick the tool that best fits the user's question
|
||||
and explain which upstream service answered when you respond.
|
||||
""",
|
||||
name: agentName,
|
||||
description: "Hosted agent demonstrating three MCP-tool authentication paths via a Foundry Toolbox.");
|
||||
|
||||
// Tier 3 spine (WebApplication.CreateBuilder + AddFoundryResponses + MapFoundryResponses):
|
||||
// the Foundry.Hosting package auto-maps the spec-required GET /readiness probe inside
|
||||
// MapFoundryResponses (idempotent — skipped when AgentHost or the developer already
|
||||
// mapped it), so the sample stays free of platform plumbing.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
// Pre-register the toolbox name so FoundryToolboxService resolves the foundry-toolbox://
|
||||
// marker at request time. With FOUNDRY_PROJECT_ENDPOINT injected by the platform, startup
|
||||
// MCP tools/list against the toolbox proxy is typically <100ms in-region.
|
||||
builder.Services.AddFoundryToolboxes(toolboxName);
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry
|
||||
// uses so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
|
||||
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
|
||||
app.MapDevTemporaryLocalAgentEndpoint();
|
||||
|
||||
app.Run();
|
||||
|
||||
// ── DevTemporaryTokenCredential ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable
|
||||
/// once at startup. This should NOT be used in production.
|
||||
///
|
||||
/// Generate a token on your host and pass it to the container:
|
||||
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> this.GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> new(this.GetAccessToken());
|
||||
|
||||
private AccessToken GetAccessToken()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(this._token, DateTimeOffset.MaxValue);
|
||||
}
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
# Hosted Toolbox — Authentication Paths
|
||||
|
||||
A hosted Foundry agent backed by a single Foundry Toolbox that bundles MCP tools using **three different authentication paths**. The educational surface lives in the toolbox configuration (which you provision in the Foundry portal) and in this README — the agent code itself is identical to the existing [`Hosted-Toolbox/`](../Hosted-Toolbox/) sample.
|
||||
|
||||
Drive the agent interactively across the auth paths with the shared [`Using-Samples/SimpleAgent/`](../Using-Samples/SimpleAgent/) REPL client, pointed at this agent.
|
||||
|
||||
## What this sample teaches
|
||||
|
||||
| Aspect | This sample | Existing siblings |
|
||||
|---|---|---|
|
||||
| Toolbox marker pattern | `FoundryAITool.CreateHostedMcpToolbox(name)` + `AddFoundryToolboxes(name)` | Same as [`Hosted-Toolbox/`](../Hosted-Toolbox/) |
|
||||
| Tools per toolbox | **Three MCP tools, each with a different auth method** | `Hosted-Toolbox/`: typically one demo tool |
|
||||
| Consumption | Server-side (Foundry resolves the marker) | Same |
|
||||
| Client | Shared [`Using-Samples/SimpleAgent/`](../Using-Samples/SimpleAgent/) REPL, pointed at this agent | `Hosted-Toolbox/`: any client |
|
||||
|
||||
Related samples:
|
||||
- [`Hosted-Toolbox/`](../Hosted-Toolbox/) — simpler single-tool toolbox.
|
||||
- [`Hosted-McpTools/`](../Hosted-McpTools/) — contrasts client-side `McpClient` vs server-side `HostedMcpServerTool` for non-toolbox MCP servers.
|
||||
|
||||
## Authentication-path matrix
|
||||
|
||||
The sample's purpose is to enumerate every authentication path a Foundry toolbox can drive, so each path appears alongside the others. Pick the ones your scenario needs — each connection in a toolbox is independent.
|
||||
|
||||
| # | Auth method | MCP target | Connection `authType` | What flows where | When to pick this |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | **Key-based via project connection** | GitHub MCP at `https://api.githubcopilot.com/mcp` | `CustomKeys` | A PAT stored as `Authorization: Bearer <pat>` lives in the Foundry connection. The toolbox proxy reads it server-side and injects on every MCP call. | The upstream service only accepts API keys or PATs. |
|
||||
| 2 | **Microsoft Entra — agent identity** | Any Azure Cognitive Services MCP endpoint your project can reach (e.g., Language service MCP) | `AgenticIdentityToken` | Foundry mints an Entra token for the agent's own identity (`instance_identity` in the new agent object model), scoped to the connection's `audience`, and forwards it to the MCP server. The agent identity must hold the required role (typically `Cognitive Services User`) on the target resource. | Per-agent least-privilege access to Entra-protected services. Recommended default for new agents. |
|
||||
| 3 | **Inline `Authorization` (anti-pattern)** | `https://gitmcp.io/Azure/azure-rest-api-specs` | none | A literal bearer string lives on the toolbox tool entry's `authorization` field. **Do not do this in production** — there's no rotation, no secret store, no per-user identity. Shown for completeness. | Local-dev or public MCP servers that accept any (or no) bearer. |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### 0. (Path #2 only) Identify an Entra-authenticated MCP target
|
||||
|
||||
Path #2 requires an MCP server that accepts Microsoft Entra tokens. Any **Azure Cognitive Services** resource that exposes an MCP endpoint works — they all accept Entra ID tokens and gate access via standard RBAC.
|
||||
|
||||
The reference walkthrough below uses an **Azure Language service** MCP endpoint:
|
||||
|
||||
```
|
||||
https://<your-language-service>.cognitiveservices.azure.com/language/mcp?api-version=2025-11-15-preview
|
||||
```
|
||||
|
||||
Substitute any other Cognitive Services MCP endpoint you have. If your project has none, omit tool #2 from your toolbox — the remaining two paths still work.
|
||||
|
||||
#### RBAC for path #2
|
||||
|
||||
Grant the **`Cognitive Services User`** role on the target resource to the agent's instance identity. Find it on the agent ARM resource (Azure portal → your agent → JSON view) at `instance_identity.principal_id`. This is the principal the Foundry proxy uses when minting tokens for `AgenticIdentityToken` connections.
|
||||
|
||||
```powershell
|
||||
$lang = "/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.CognitiveServices/accounts/<lang-svc>"
|
||||
|
||||
az role assignment create `
|
||||
--assignee-object-id <agent-instance-identity-principal-id> `
|
||||
--assignee-principal-type ServicePrincipal `
|
||||
--role "Cognitive Services User" `
|
||||
--scope $lang
|
||||
```
|
||||
|
||||
Repeat for any additional Cognitive Services resources the agent identity needs to call.
|
||||
|
||||
> The RBAC grant requires `Microsoft.Authorization/roleAssignments/write` on the target scope. In many enterprise subscriptions this needs a PIM JIT activation.
|
||||
|
||||
### 1. Foundry project + Azure AI User role
|
||||
|
||||
- An active Microsoft Foundry project ([create one](https://learn.microsoft.com/en-us/azure/foundry/how-to/create-projects)).
|
||||
- The **Azure AI User** role on the project assigned to:
|
||||
- The developer (you) creating the toolbox.
|
||||
- The agent identity for tool invocation.
|
||||
|
||||
### 2. Create the project connections
|
||||
|
||||
The Entra-based connection (path #2) is not available in the Foundry portal connection wizard today. Create it via ARM REST:
|
||||
|
||||
```powershell
|
||||
$armToken = az account get-access-token --query accessToken -o tsv
|
||||
$h = @{ Authorization = "Bearer $armToken"; "Content-Type" = "application/json" }
|
||||
$proj = "/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.CognitiveServices/accounts/<foundry-account>/projects/<project>"
|
||||
$lang = "https://<lang-svc>.cognitiveservices.azure.com/language/mcp?api-version=2025-11-15-preview"
|
||||
|
||||
# Path 2 — agent identity
|
||||
$body2 = @{ properties = @{
|
||||
category = "RemoteTool"; target = $lang
|
||||
authType = "AgenticIdentityToken"; audience = "https://cognitiveservices.azure.com"
|
||||
isSharedToAll = $false
|
||||
}} | ConvertTo-Json -Depth 5
|
||||
az rest --method PUT --headers "Content-Type=application/json" `
|
||||
--url "https://management.azure.com$proj/connections/lang-mcp-agent-id?api-version=2025-04-01-preview" `
|
||||
--body $body2
|
||||
```
|
||||
|
||||
Connection summary:
|
||||
|
||||
| Connection name (used by the toolbox) | `category` | `authType` | `audience` |
|
||||
|---|---|---|---|
|
||||
| `github-mcp-key` | `CustomKeys` | `CustomKeys` | n/a (key value carries `Authorization: Bearer <pat>`) |
|
||||
| `lang-mcp-agent-id` | `RemoteTool` | `AgenticIdentityToken` | `https://cognitiveservices.azure.com` |
|
||||
|
||||
Path #3 (`gitmcp.io`) needs no connection — the auth lives on the toolbox tool entry itself.
|
||||
|
||||
The `audience` value is the token resource identifier of the target service — for any Cognitive Services resource it is `https://cognitiveservices.azure.com`. For other Azure services consult [Agent identity — runtime token exchange](https://learn.microsoft.com/azure/foundry/agents/concepts/agent-identity#runtime-token-exchange).
|
||||
|
||||
### 3. Create the toolbox
|
||||
|
||||
In the Foundry portal → Tools → Add Toolbox. Name it `auth-paths-toolbox` (or whatever you prefer; export the name as `TOOLBOX_NAME`). Add three MCP tool entries:
|
||||
|
||||
| Tool `server_label` | `server_url` | Auth |
|
||||
|---|---|---|
|
||||
| `github_pat` | `https://api.githubcopilot.com/mcp` | `project_connection_id: github-mcp-key` |
|
||||
| `lang_agent` | Your Language service MCP URL | `project_connection_id: lang-mcp-agent-id` |
|
||||
| `gitmcp_inline` | `https://gitmcp.io/Azure/azure-rest-api-specs` | `authorization: "Bearer demo-only-not-real"` (no `project_connection_id`) |
|
||||
|
||||
Each entry should also carry:
|
||||
|
||||
- `require_approval: never` (this sample is focused on auth, not approval flows; see [`ToolCallingApprovalHostedAgentFixture.cs`](../../../../../tests/Foundry.Hosting.IntegrationTests/Fixtures/ToolCallingApprovalHostedAgentFixture.cs) for that concern).
|
||||
- A tight `allowed_tools` list. GitHub MCP exposes ~50 tools; restrict to what you actually want the model to invoke. For example: `github_pat` → `["search_issues", "list_pull_requests"]`. **Every name in `allowed_tools` must match a real tool on the upstream server** — an unknown name (e.g., `get_issue`, which GitHub MCP does not expose) makes the whole source fail enumeration. See the partial-failure note below.
|
||||
|
||||
### Sidebar — what the toolbox-creation code looks like
|
||||
|
||||
This sample assumes the toolbox already exists; it does not provision one programmatically. For an end-to-end code example of toolbox creation from a publisher script (suitable for a CI/CD pipeline), see [`02-agents/AgentsWithFoundry/Agent_Step25_FoundryToolboxMcp/Program.cs`](../../../../02-agents/AgentsWithFoundry/Agent_Step25_FoundryToolboxMcp/Program.cs) — its `CreateSampleToolboxAsync` helper uses `AgentAdministrationClient.GetAgentToolboxes().CreateToolboxVersionAsync(...)` and is the canonical pattern.
|
||||
|
||||
## Run the agent
|
||||
|
||||
Set environment variables (or copy `.env.example` to `.env` and fill it in):
|
||||
|
||||
```powershell
|
||||
$env:AZURE_AI_PROJECT_ENDPOINT = "https://<account>.services.ai.azure.com/api/projects/<project>"
|
||||
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME = "gpt-4o"
|
||||
$env:TOOLBOX_NAME = "auth-paths-toolbox"
|
||||
```
|
||||
|
||||
Locally, the `Foundry.Hosting` package reads `AZURE_AI_PROJECT_ENDPOINT` as a fallback when `FOUNDRY_PROJECT_ENDPOINT` is absent. In the hosted Foundry runtime, the platform auto-injects `FOUNDRY_PROJECT_ENDPOINT` and the package builds the toolbox proxy URL as `{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{TOOLBOX_NAME}/mcp?api-version=v1` per [`tools-integration-spec.md`](https://github.com/microsoft/AgentSchema/blob/main/specs/agents/hosted_agents/container-spec/docs/tools-integration-spec.md) §2–§3.
|
||||
|
||||
Then sign in (`az login`) and start the server:
|
||||
|
||||
```powershell
|
||||
dotnet run --tl:off
|
||||
```
|
||||
|
||||
The server logs at `http://localhost:8088/`. In Development it also maps the per-agent OpenAI route shape (`MapDevTemporaryLocalAgentEndpoint()`), so the shared `SimpleAgent` REPL client can reach it through `AsAIAgent(agentEndpoint)` — the only supported way to consume a hosted Foundry agent. In a separate terminal:
|
||||
|
||||
**Against the local dev server** (point the client at localhost; the `{project}` segment is a wildcard the server ignores):
|
||||
|
||||
```powershell
|
||||
cd ../Using-Samples/SimpleAgent
|
||||
$env:AZURE_AI_PROJECT_ENDPOINT = "http://localhost:8088/api/projects/local"
|
||||
$env:AZURE_AI_AGENT_NAME = "hosted-toolbox-auth-paths-agent"
|
||||
dotnet run --tl:off
|
||||
```
|
||||
|
||||
**Against a deployed agent** (point the client at the real project endpoint and the deployed agent name):
|
||||
|
||||
```powershell
|
||||
cd ../Using-Samples/SimpleAgent
|
||||
$env:AZURE_AI_PROJECT_ENDPOINT = "https://<account>.services.ai.azure.com/api/projects/<project>"
|
||||
$env:AZURE_AI_AGENT_NAME = "hosted-toolbox-auth-paths-agent"
|
||||
dotnet run --tl:off
|
||||
```
|
||||
|
||||
Either way the client derives the per-agent endpoint URL (`{AZURE_AI_PROJECT_ENDPOINT}/agents/{AZURE_AI_AGENT_NAME}/endpoint/protocols/openai`) and consumes the agent via `AsAIAgent(agentEndpoint)`. Run `az login` first so the client can mint a bearer token.
|
||||
|
||||
> **Parallel-run warning**: `Hosted-Toolbox/` and other `Hosted-*` samples default to the same port (8088) and the same agent name slot. Always set a unique `AGENT_NAME` (this sample defaults to `hosted-toolbox-auth-paths-agent`) and stop other hosted samples before starting this one.
|
||||
|
||||
## Sample prompts
|
||||
|
||||
One per auth path so each tool gets exercised at least once:
|
||||
|
||||
```
|
||||
List the latest 3 issues in microsoft/agent-framework. # path #1 — GitHub MCP (key)
|
||||
Detect the language of "Bonjour le monde". # path #2 — Language MCP (agent identity)
|
||||
What's the latest API version for Microsoft.CognitiveServices? # path #3 — gitmcp.io (inline Authorization)
|
||||
```
|
||||
|
||||
## Troubleshooting / partial-failure semantics
|
||||
|
||||
`AddFoundryToolboxes` resolves the toolbox at startup by listing its tools via MCP `tools/list`. This enumeration is **all-or-nothing**: if *any* single tool source fails to enumerate, the Foundry toolbox proxy returns a top-level JSON-RPC error (`-32007`) instead of a partial list, the hosting package marks the toolbox startup as failed, `/readiness` returns 503, and *every* invoke against the agent returns **HTTP 424** — even for the auth paths that are configured correctly. So one misconfigured connection or one bad `allowed_tools` entry bricks the whole agent at startup, not just at tool-call time. Get each source enumerating cleanly before deploying. Symptoms per auth path:
|
||||
|
||||
| Symptom | Likely cause |
|
||||
|---|---|
|
||||
| **All invokes return HTTP 424 ("Failed Dependency")** | One or more tool sources failed `tools/list` at startup (see all-or-nothing note above). Common causes: an `allowed_tools` name that does not exist on the upstream server, or an Entra connection whose token is rejected. Reproduce by calling the toolbox `tools/list` directly with your own token — a `-32007` top-level error names the failing source. |
|
||||
| **HTTP 401 "audience is incorrect"** | The connection's `audience` field is missing or does not match the OAuth resource identifier the target service accepts. For Cognitive Services targets, set `audience: "https://cognitiveservices.azure.com"`. |
|
||||
| **HTTP 401 / 403 "principal does not have access"** | Path #1: PAT expired or scope insufficient. Path #2: the agent's instance identity is missing the required role on the target resource. |
|
||||
| **Container reports zero tools but startup succeeded** | `FoundryToolboxService.StartAsync` caches the `tools/list` result at startup. If a connection or RBAC grant changed after the container started, force a fresh container (re-deploy the agent version) — the cache won't pick up the change until then. |
|
||||
| **HTTP 404 from a tool call** | Toolbox name mismatch (`TOOLBOX_NAME` vs the name in the portal), or the toolbox was deleted. |
|
||||
| **Server logs a warning "Neither FOUNDRY_PROJECT_ENDPOINT nor AZURE_AI_PROJECT_ENDPOINT is set; toolbox support is disabled"** | Local dev without the env var set. The agent will load with zero tools and respond as if it has none. Set `AZURE_AI_PROJECT_ENDPOINT` (local-dev fallback) or `FOUNDRY_PROJECT_ENDPOINT` to your project endpoint. |
|
||||
| **Tools appear but model never invokes them** | `instructions:` in `Program.cs` may not surface what each tool is for. Tighten the `allowed_tools` lists and rephrase prompts to mention the upstream service by name. |
|
||||
|
||||
## Region and model compatibility
|
||||
|
||||
Foundry Toolboxes have region constraints; some tool types are limited to specific models. This sample defaults to `gpt-4o`, which works in all supported regions. For the full matrix, see the [Foundry tools compatibility matrix](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/toolbox#region-and-model-compatibility).
|
||||
|
||||
## Anti-pattern note for path #3
|
||||
|
||||
Inline `authorization` on a toolbox tool entry stores credentials **inside the toolbox definition**. There is no rotation, no per-user scoping, no secret-store integration. Use it only for:
|
||||
|
||||
- Public MCP servers that ignore the bearer (the `gitmcp.io` case demonstrated here).
|
||||
- Local development against a test MCP server with a throwaway token.
|
||||
|
||||
For everything else use `project_connection_id` and let the platform inject credentials.
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
|
||||
name: hosted-toolbox-auth-paths
|
||||
displayName: "Hosted Toolbox - Authentication Paths"
|
||||
|
||||
description: >
|
||||
A hosted agent demonstrating three MCP-tool authentication paths in a single
|
||||
Foundry Toolbox: API key via project connection, Microsoft Entra agent
|
||||
identity, and inline Authorization
|
||||
(anti-pattern). The toolbox itself is
|
||||
provisioned out of band; see this sample's README for the portal walkthrough.
|
||||
|
||||
metadata:
|
||||
tags:
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Responses Protocol
|
||||
- Agent Framework
|
||||
- Foundry Toolbox
|
||||
- Authentication
|
||||
- MCP
|
||||
|
||||
template:
|
||||
name: hosted-toolbox-auth-paths
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
environment_variables:
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
|
||||
- name: TOOLBOX_NAME
|
||||
value: "{{TOOLBOX_NAME}}"
|
||||
parameters:
|
||||
properties:
|
||||
- name: TOOLBOX_NAME
|
||||
type: string
|
||||
default: "auth-paths-toolbox"
|
||||
description: "Name of the Foundry Toolbox to load at runtime."
|
||||
resources:
|
||||
- kind: model
|
||||
id: gpt-4o
|
||||
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
- kind: toolbox
|
||||
name: "{{TOOLBOX_NAME}}"
|
||||
tools: []
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
|
||||
kind: hosted
|
||||
name: hosted-toolbox-auth-paths
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
@@ -1,21 +1,27 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Foundry Toolbox Agent - A hosted agent that uses Foundry Toolset MCP tools.
|
||||
// Foundry Toolbox Agent - A hosted agent that uses Foundry Toolbox MCP tools.
|
||||
//
|
||||
// Demonstrates how to register one or more Foundry toolsets so the agent can
|
||||
// Demonstrates how to register one or more Foundry toolboxes so the agent can
|
||||
// call tools provided by the Foundry platform's managed MCP proxy.
|
||||
//
|
||||
// Required environment variables:
|
||||
// AZURE_AI_PROJECT_ENDPOINT - Azure AI Foundry project endpoint
|
||||
// AZURE_AI_PROJECT_ENDPOINT (local-dev) OR FOUNDRY_PROJECT_ENDPOINT (hosted runtime)
|
||||
// - Azure AI Foundry project endpoint. The Foundry hosted
|
||||
// runtime auto-injects FOUNDRY_PROJECT_ENDPOINT; locally
|
||||
// set AZURE_AI_PROJECT_ENDPOINT.
|
||||
// AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-4o)
|
||||
// FOUNDRY_AGENT_TOOLSET_ENDPOINT - Foundry Toolsets proxy base URL
|
||||
// (injected automatically by Foundry platform at runtime)
|
||||
//
|
||||
// Optional:
|
||||
// FOUNDRY_TOOLBOX_NAME - Name of the toolset to load (default: my-toolset)
|
||||
// FOUNDRY_AGENT_NAME - Client name reported to MCP server
|
||||
// FOUNDRY_AGENT_VERSION - Client version reported to MCP server
|
||||
// FOUNDRY_AGENT_TOOLSET_FEATURES - Feature flags sent to Foundry proxy via header
|
||||
// TOOLBOX_NAME - Name of the toolbox to load (default: my-toolbox)
|
||||
// FOUNDRY_AGENT_NAME - Client name reported to MCP server (auto-injected in hosted runtime)
|
||||
// FOUNDRY_AGENT_VERSION - Client version reported to MCP server (auto-injected in hosted runtime)
|
||||
// FOUNDRY_AGENT_TOOLSET_FEATURES - Additional Foundry-Features header flags (the mandatory
|
||||
// Toolboxes=V1Preview flag is always sent; this env var
|
||||
// appends additional flags if present).
|
||||
//
|
||||
// The Foundry.Hosting package builds the toolbox proxy URL from FOUNDRY_PROJECT_ENDPOINT
|
||||
// per tools-integration-spec.md §2–§3.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
@@ -28,10 +34,13 @@ using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException(
|
||||
"Neither FOUNDRY_PROJECT_ENDPOINT (platform-injected in hosted runtime) " +
|
||||
"nor AZURE_AI_PROJECT_ENDPOINT (local-dev convention) is set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
|
||||
string toolboxName = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_NAME") ?? "my-toolset";
|
||||
string toolboxName = Environment.GetEnvironmentVariable("TOOLBOX_NAME") ?? "my-toolbox";
|
||||
|
||||
// Use a chained credential: try a temporary dev token first (for local Docker debugging),
|
||||
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production).
|
||||
@@ -45,12 +54,12 @@ AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
|
||||
.AsAIAgent(
|
||||
model: deploymentName,
|
||||
instructions: """
|
||||
You are a helpful assistant with access to tools provided by the Foundry Toolset.
|
||||
You are a helpful assistant with access to tools provided by the Foundry Toolbox.
|
||||
Use the available tools to answer user questions.
|
||||
If a tool is not available for a request, let the user know clearly.
|
||||
""",
|
||||
name: Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-toolbox-agent",
|
||||
description: "Hosted agent backed by Foundry Toolset MCP tools");
|
||||
description: "Hosted agent backed by Foundry Toolbox MCP tools");
|
||||
|
||||
// ── Build the host ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -61,8 +70,8 @@ builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
// Register Foundry Toolbox: connects to the MCP proxy at startup and makes tools available.
|
||||
// The toolset name must match a toolset registered in your Foundry project.
|
||||
// When FOUNDRY_AGENT_TOOLSET_ENDPOINT is absent (e.g., in local development without Foundry
|
||||
// The toolbox name must match a toolbox registered in your Foundry project.
|
||||
// When FOUNDRY_PROJECT_ENDPOINT is absent (e.g., in local development without Foundry
|
||||
// infrastructure), startup succeeds without error and no toolbox tools are loaded.
|
||||
builder.Services.AddFoundryToolboxes(toolboxName);
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Hosted-Toolbox
|
||||
|
||||
A hosted Foundry agent that loads tools from a Foundry Toolbox via the AF Foundry hosting bridge.
|
||||
|
||||
The agent declares one `FoundryAITool.CreateHostedMcpToolbox(name)` marker; `AddFoundryToolboxes(name)` registers a `FoundryToolboxService` that resolves the marker into the individual MCP tools the toolbox bundles, connecting to the Foundry Toolboxes MCP proxy at startup and discovering tools via `tools/list`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Microsoft Foundry project with a Toolbox configured.
|
||||
- Azure CLI logged in (`az login`).
|
||||
- Set environment variables:
|
||||
- `AZURE_AI_PROJECT_ENDPOINT` (local-dev) or `FOUNDRY_PROJECT_ENDPOINT` (auto-injected in hosted containers)
|
||||
- `AZURE_AI_MODEL_DEPLOYMENT_NAME` (default `gpt-4o`)
|
||||
- `TOOLBOX_NAME` (default `my-toolbox`)
|
||||
|
||||
The `Foundry.Hosting` package builds the toolbox proxy URL from `FOUNDRY_PROJECT_ENDPOINT` as `{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{TOOLBOX_NAME}/mcp?api-version=v1` per [`tools-integration-spec.md`](https://github.com/microsoft/AgentSchema/blob/main/specs/agents/hosted_agents/container-spec/docs/tools-integration-spec.md) §2–§3.
|
||||
|
||||
## Run
|
||||
|
||||
```powershell
|
||||
dotnet run --tl:off
|
||||
```
|
||||
|
||||
## Related samples
|
||||
|
||||
- [`Hosted-Toolbox-AuthPaths/`](../Hosted-Toolbox-AuthPaths/) — extends this pattern with a three-tool toolbox demonstrating different MCP-tool authentication paths (key, Entra agent identity, inline `Authorization`), driven by the shared `Using-Samples/SimpleAgent/` REPL.
|
||||
- [`Hosted-McpTools/`](../Hosted-McpTools/) — contrasts client-side `McpClient` vs server-side `HostedMcpServerTool` for non-toolbox MCP servers.
|
||||
+87
-10
@@ -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,
|
||||
}
|
||||
+1
-1
@@ -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>
|
||||
|
||||
@@ -54,7 +54,6 @@ builder.Services.AddFoundryResponses(agent);
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
app.MapGet("/readiness", () => Results.Ok());
|
||||
app.Run();
|
||||
|
||||
static AIAgent CreateHappyPathAgent(AIProjectClient client, string deployment) =>
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// xUnit collection that serializes tests mutating the <c>FOUNDRY_PROJECT_ENDPOINT</c>
|
||||
/// process environment variable. Without this, parallel test execution causes flaky
|
||||
/// races between tests that set / unset the variable.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name, DisableParallelization = true)]
|
||||
public sealed class FoundryProjectEndpointEnvFixture
|
||||
{
|
||||
public const string Name = "FoundryProjectEndpointEnv";
|
||||
}
|
||||
+126
-7
@@ -1,6 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
@@ -51,28 +54,144 @@ public class FoundryToolboxBearerTokenHandlerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_InjectsFoundryFeaturesHeaderAsync()
|
||||
public async Task SendAsync_UsesAiAzureComScopeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var capturedContexts = new List<TokenRequestContext>();
|
||||
var credential = new Mock<TokenCredential>();
|
||||
credential
|
||||
.Setup(c => c.GetTokenAsync(It.IsAny<TokenRequestContext>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<TokenRequestContext, CancellationToken>((ctx, _) => capturedContexts.Add(ctx))
|
||||
.ReturnsAsync(new AccessToken(FakeToken, DateTimeOffset.MaxValue));
|
||||
var (handler, _) = CreateHandlerPair(credential);
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
// Act
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert: spec §4 mandates the https://ai.azure.com audience.
|
||||
Assert.Single(capturedContexts);
|
||||
Assert.Contains("https://ai.azure.com/.default", capturedContexts[0].Scopes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_AlwaysInjectsMandatoryFoundryFeaturesHeaderAsync()
|
||||
{
|
||||
// Arrange
|
||||
var (handler, _) = CreateHandlerPair(featuresHeader: null);
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
// Act
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
using var response = await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert: spec §2 requires Foundry-Features: Toolboxes=V1Preview on every request.
|
||||
Assert.True(request.Headers.TryGetValues("Foundry-Features", out var values));
|
||||
Assert.Equal("Toolboxes=V1Preview", values.Single());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_MergesMandatoryAndOverrideFeaturesAsync()
|
||||
{
|
||||
var (handler, _) = CreateHandlerPair(featuresHeader: "feature1,feature2");
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
using var response = await invoker.SendAsync(request, CancellationToken.None);
|
||||
await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.True(request.Headers.TryGetValues("Foundry-Features", out var values));
|
||||
Assert.Contains("feature1,feature2", values);
|
||||
var header = values.Single();
|
||||
Assert.Contains("Toolboxes=V1Preview", header, StringComparison.Ordinal);
|
||||
Assert.Contains("feature1", header, StringComparison.Ordinal);
|
||||
Assert.Contains("feature2", header, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_OmitsFeaturesHeaderWhenNullAsync()
|
||||
public async Task SendAsync_DoesNotDuplicateMandatoryFlagAsync()
|
||||
{
|
||||
var (handler, _) = CreateHandlerPair(featuresHeader: null);
|
||||
// Override already contains the mandatory flag — must not be duplicated in the merged value.
|
||||
var (handler, _) = CreateHandlerPair(featuresHeader: "Toolboxes=V1Preview");
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
using var response = await invoker.SendAsync(request, CancellationToken.None);
|
||||
await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.False(request.Headers.Contains("Foundry-Features"));
|
||||
Assert.True(request.Headers.TryGetValues("Foundry-Features", out var values));
|
||||
var header = values.Single();
|
||||
var count = 0;
|
||||
var idx = 0;
|
||||
while ((idx = header.IndexOf("Toolboxes=V1Preview", idx, StringComparison.OrdinalIgnoreCase)) >= 0)
|
||||
{
|
||||
count++;
|
||||
idx += "Toolboxes=V1Preview".Length;
|
||||
}
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_PropagatesTraceContextFromActivityAsync()
|
||||
{
|
||||
// Arrange: activate an Activity so Activity.Current is populated.
|
||||
using var listener = new ActivityListener
|
||||
{
|
||||
ShouldListenTo = _ => true,
|
||||
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
|
||||
};
|
||||
ActivitySource.AddActivityListener(listener);
|
||||
using var source = new ActivitySource("test-source");
|
||||
using var activity = source.StartActivity("test-op")!;
|
||||
Assert.NotNull(activity);
|
||||
activity.TraceStateString = "vendor=value";
|
||||
activity.AddBaggage("user", "alice");
|
||||
|
||||
var (handler, _) = CreateHandlerPair();
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
// Act
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert: spec §6.3 requires traceparent/tracestate/baggage propagation.
|
||||
Assert.True(request.Headers.TryGetValues("traceparent", out var tpValues));
|
||||
Assert.Contains(activity.TraceId.ToString(), tpValues.Single(), StringComparison.Ordinal);
|
||||
|
||||
Assert.True(request.Headers.TryGetValues("tracestate", out var tsValues));
|
||||
Assert.Equal("vendor=value", tsValues.Single());
|
||||
|
||||
Assert.True(request.Headers.TryGetValues("baggage", out var bgValues));
|
||||
Assert.Contains("user=alice", bgValues.Single(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_DoesNotOverrideExistingTraceparentAsync()
|
||||
{
|
||||
// Caller pre-set traceparent on the message; must not be duplicated or replaced.
|
||||
using var listener = new ActivityListener
|
||||
{
|
||||
ShouldListenTo = _ => true,
|
||||
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
|
||||
};
|
||||
ActivitySource.AddActivityListener(listener);
|
||||
using var source = new ActivitySource("test-source");
|
||||
using var activity = source.StartActivity("test-op")!;
|
||||
Assert.NotNull(activity);
|
||||
|
||||
var (handler, _) = CreateHandlerPair();
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
const string PresetTraceparent = "00-00000000000000000000000000000001-0000000000000001-01";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
request.Headers.TryAddWithoutValidation("traceparent", PresetTraceparent);
|
||||
|
||||
// Act
|
||||
await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(request.Headers.TryGetValues("traceparent", out var values));
|
||||
var list = values.ToList();
|
||||
Assert.Single(list);
|
||||
Assert.Equal(PresetTraceparent, list[0]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Core;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
[Collection(FoundryProjectEndpointEnvFixture.Name)]
|
||||
public class FoundryToolboxHealthCheckTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_PendingStatus_ReturnsConfiguredFailureAsync()
|
||||
{
|
||||
// Arrange: a fresh FoundryToolboxService whose StartAsync has never run reports
|
||||
// Pending. The health check must surface that as the registration's failure
|
||||
// status so the platform waits before sending traffic.
|
||||
var service = CreateServiceWithoutStarting();
|
||||
var check = new FoundryToolboxHealthCheck(service);
|
||||
var context = NewContext(failureStatus: HealthStatus.Unhealthy);
|
||||
|
||||
// Act
|
||||
var result = await check.CheckHealthAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthStatus.Unhealthy, result.Status);
|
||||
Assert.Contains("startup has not completed", result.Description, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_NoEndpointStatus_ReturnsHealthyAsync()
|
||||
{
|
||||
// Arrange: no FOUNDRY_PROJECT_ENDPOINT / AZURE_AI_PROJECT_ENDPOINT is normal local-dev.
|
||||
// The container must still pass readiness because the rest of the agent is functional.
|
||||
var savedFoundry = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT");
|
||||
var savedAzure = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT");
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", null);
|
||||
Environment.SetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT", null);
|
||||
try
|
||||
{
|
||||
var service = CreateServiceWithoutStarting(toolbox: "any");
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
var check = new FoundryToolboxHealthCheck(service);
|
||||
var context = NewContext(failureStatus: HealthStatus.Unhealthy);
|
||||
|
||||
// Act
|
||||
var result = await check.CheckHealthAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthStatus.Healthy, result.Status);
|
||||
Assert.Equal(FoundryToolboxStartupStatus.NoEndpoint, service.StartupStatus);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", savedFoundry);
|
||||
Environment.SetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT", savedAzure);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_UnhealthyStatus_ReturnsConfiguredFailureWithFailedNamesAsync()
|
||||
{
|
||||
// Arrange: pre-registered toolbox at an unreachable endpoint forces StartAsync to
|
||||
// record the failure. The health-check must reflect Unhealthy and expose the
|
||||
// failed toolbox names in the result data so operators can diagnose without log
|
||||
// diving.
|
||||
var options = new FoundryToolboxOptions
|
||||
{
|
||||
EndpointOverride = "http://127.0.0.1:1/unreachable",
|
||||
};
|
||||
options.ToolboxNames.Add("broken-toolbox");
|
||||
var service = new FoundryToolboxService(Options.Create(options), Mock.Of<TokenCredential>());
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
var check = new FoundryToolboxHealthCheck(service);
|
||||
var context = NewContext(failureStatus: HealthStatus.Unhealthy);
|
||||
|
||||
// Act
|
||||
var result = await check.CheckHealthAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthStatus.Unhealthy, result.Status);
|
||||
Assert.True(result.Data.ContainsKey("failedToolboxes"));
|
||||
var failed = Assert.IsAssignableFrom<IReadOnlyList<string>>(result.Data["failedToolboxes"]);
|
||||
Assert.Equal("broken-toolbox", Assert.Single(failed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_HealthyStatus_ReturnsHealthyAsync()
|
||||
{
|
||||
// Arrange: an endpoint set but no pre-registered toolboxes is the legitimate
|
||||
// lazy-only setup. StartAsync reports Healthy and the check must agree.
|
||||
var options = new FoundryToolboxOptions
|
||||
{
|
||||
EndpointOverride = "http://127.0.0.1:1/unused",
|
||||
};
|
||||
var service = new FoundryToolboxService(Options.Create(options), Mock.Of<TokenCredential>());
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
var check = new FoundryToolboxHealthCheck(service);
|
||||
var context = NewContext(failureStatus: HealthStatus.Unhealthy);
|
||||
|
||||
// Act
|
||||
var result = await check.CheckHealthAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthStatus.Healthy, result.Status);
|
||||
}
|
||||
|
||||
private static FoundryToolboxService CreateServiceWithoutStarting(string? toolbox = null)
|
||||
{
|
||||
var options = new FoundryToolboxOptions();
|
||||
if (toolbox is not null)
|
||||
{
|
||||
options.ToolboxNames.Add(toolbox);
|
||||
}
|
||||
return new FoundryToolboxService(Options.Create(options), Mock.Of<TokenCredential>());
|
||||
}
|
||||
|
||||
private static HealthCheckContext NewContext(HealthStatus failureStatus) =>
|
||||
new()
|
||||
{
|
||||
Registration = new HealthCheckRegistration(
|
||||
name: "foundry-toolbox",
|
||||
instance: Mock.Of<IHealthCheck>(),
|
||||
failureStatus: failureStatus,
|
||||
tags: null),
|
||||
};
|
||||
}
|
||||
+155
-5
@@ -9,6 +9,7 @@ using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
[Collection(FoundryProjectEndpointEnvFixture.Name)]
|
||||
public class FoundryToolboxServiceTests
|
||||
{
|
||||
[Fact]
|
||||
@@ -39,15 +40,17 @@ public class FoundryToolboxServiceTests
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
async () => await service.GetToolboxToolsAsync("missing", version: null, CancellationToken.None));
|
||||
|
||||
Assert.Contains("FOUNDRY_AGENT_TOOLSET_ENDPOINT", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("FOUNDRY_PROJECT_ENDPOINT", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WithoutEndpoint_LeavesToolsEmptyAsync()
|
||||
{
|
||||
// Ensure env var is not set (tests may run in any CI environment)
|
||||
var saved = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT");
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT", null);
|
||||
// Ensure neither env var is set (tests may run in any CI environment)
|
||||
var savedFoundry = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT");
|
||||
var savedAzure = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT");
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", null);
|
||||
Environment.SetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT", null);
|
||||
try
|
||||
{
|
||||
var options = new FoundryToolboxOptions();
|
||||
@@ -59,10 +62,157 @@ public class FoundryToolboxServiceTests
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.Empty(service.Tools);
|
||||
Assert.Equal(FoundryToolboxStartupStatus.NoEndpoint, service.StartupStatus);
|
||||
Assert.Empty(service.FailedToolboxNames);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT", saved);
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", savedFoundry);
|
||||
Environment.SetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT", savedAzure);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_AttemptsOpenForPreRegisteredToolboxFromProjectEndpointAsync()
|
||||
{
|
||||
// Arrange: point the service at an unreachable host and confirm StartAsync
|
||||
// attempts to open the pre-registered toolbox (verified via FailedToolboxNames
|
||||
// recording the attempted name and StartupStatus reflecting the failure).
|
||||
var saved = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT");
|
||||
Environment.SetEnvironmentVariable(
|
||||
"FOUNDRY_PROJECT_ENDPOINT",
|
||||
"https://example.invalid/api/projects/proj");
|
||||
try
|
||||
{
|
||||
var options = new FoundryToolboxOptions { ApiVersion = "v1" };
|
||||
options.ToolboxNames.Add("my-toolbox");
|
||||
var service = new FoundryToolboxService(
|
||||
Options.Create(options),
|
||||
Mock.Of<TokenCredential>());
|
||||
|
||||
// Act: StartAsync attempts to connect to the invalid endpoint and fails.
|
||||
// The failure path records FailedToolboxNames; the value confirms the resolver ran.
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
// Assert: open failed, status reflects that (resolver was reached), and
|
||||
// the failed name matches — i.e. we attempted the right toolbox.
|
||||
Assert.Equal(FoundryToolboxStartupStatus.Unhealthy, service.StartupStatus);
|
||||
Assert.Single(service.FailedToolboxNames);
|
||||
Assert.Equal("my-toolbox", service.FailedToolboxNames[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", saved);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_TrailingSlashOnProjectEndpoint_AttemptsOpenAsync()
|
||||
{
|
||||
var saved = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT");
|
||||
Environment.SetEnvironmentVariable(
|
||||
"FOUNDRY_PROJECT_ENDPOINT",
|
||||
"https://example.invalid/api/projects/proj/");
|
||||
try
|
||||
{
|
||||
var options = new FoundryToolboxOptions();
|
||||
options.ToolboxNames.Add("tb");
|
||||
var service = new FoundryToolboxService(
|
||||
Options.Create(options),
|
||||
Mock.Of<TokenCredential>());
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
// Arrange/Act: when trailing-slash normalization works the open still fails
|
||||
// (host is unreachable), but FailedToolboxNames records the attempted name —
|
||||
// proof that the resolver did not throw on the slash and the URL was built.
|
||||
Assert.Equal(FoundryToolboxStartupStatus.Unhealthy, service.StartupStatus);
|
||||
Assert.Single(service.FailedToolboxNames);
|
||||
Assert.Equal("tb", service.FailedToolboxNames[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", saved);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_EndpointOverrideWinsOverEnvAsync()
|
||||
{
|
||||
var saved = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT");
|
||||
Environment.SetEnvironmentVariable(
|
||||
"FOUNDRY_PROJECT_ENDPOINT",
|
||||
"https://from-env.invalid/api/projects/proj");
|
||||
try
|
||||
{
|
||||
// EndpointOverride should take precedence over the env var.
|
||||
var options = new FoundryToolboxOptions
|
||||
{
|
||||
EndpointOverride = "http://127.0.0.1:1/from-override",
|
||||
};
|
||||
options.ToolboxNames.Add("tb");
|
||||
|
||||
var service = new FoundryToolboxService(
|
||||
Options.Create(options),
|
||||
Mock.Of<TokenCredential>());
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
// Override URL is unreachable; we expect Unhealthy (proving Start did try to open
|
||||
// a toolbox, i.e. did not fall into the NoEndpoint branch).
|
||||
Assert.Equal(FoundryToolboxStartupStatus.Unhealthy, service.StartupStatus);
|
||||
Assert.Single(service.FailedToolboxNames);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", saved);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WithEndpointButFailingToolbox_RecordsFailureAndStaysReachableAsync()
|
||||
{
|
||||
// Arrange: a syntactically valid but unreachable endpoint forces OpenToolboxAsync
|
||||
// to throw inside the catch-and-log path. The service must still complete StartAsync
|
||||
// (so the host doesn't crash) and surface the failure via StartupStatus.
|
||||
var options = new FoundryToolboxOptions
|
||||
{
|
||||
EndpointOverride = "http://127.0.0.1:1/unreachable",
|
||||
};
|
||||
options.ToolboxNames.Add("broken-toolbox");
|
||||
|
||||
var service = new FoundryToolboxService(
|
||||
Options.Create(options),
|
||||
Mock.Of<TokenCredential>());
|
||||
|
||||
// Act
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(FoundryToolboxStartupStatus.Unhealthy, service.StartupStatus);
|
||||
Assert.Single(service.FailedToolboxNames);
|
||||
Assert.Equal("broken-toolbox", service.FailedToolboxNames[0]);
|
||||
Assert.Empty(service.Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WithEndpointAndNoToolboxes_ReportsHealthyAsync()
|
||||
{
|
||||
// No pre-registered toolboxes is a legitimate "lazy-only" setup. Health-check
|
||||
// should report Healthy so the readiness probe passes.
|
||||
var options = new FoundryToolboxOptions
|
||||
{
|
||||
EndpointOverride = "http://127.0.0.1:1/unused",
|
||||
};
|
||||
|
||||
var service = new FoundryToolboxService(
|
||||
Options.Create(options),
|
||||
Mock.Of<TokenCredential>());
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(FoundryToolboxStartupStatus.Healthy, service.StartupStatus);
|
||||
Assert.Empty(service.FailedToolboxNames);
|
||||
Assert.Empty(service.Tools);
|
||||
}
|
||||
}
|
||||
|
||||
+75
@@ -2,9 +2,15 @@
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Moq;
|
||||
using OpenAI.Responses;
|
||||
|
||||
@@ -135,4 +141,73 @@ public class ServiceCollectionExtensionsTests
|
||||
Assert.True(typeof(IChatClient).IsAssignableFrom(meaiType!),
|
||||
$"Expected MEAI {meaiType!.FullName} to implement IChatClient.");
|
||||
}
|
||||
|
||||
// ── /readiness auto-mapping (Foundry container-image-spec §2) ────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task MapFoundryResponses_MapsReadinessEndpoint_WhenTier3HostHasNotMappedItAsync()
|
||||
{
|
||||
// Arrange: Tier 3 host (WebApplication.CreateBuilder, no AgentHost) — Core SDK does
|
||||
// NOT map /readiness in this case, so MapFoundryResponses must cover the gap.
|
||||
using var host = await BuildTestHostAsync(static app => app.MapFoundryResponses());
|
||||
|
||||
// Act
|
||||
var response = await host.GetTestClient().GetAsync(new Uri("/readiness", UriKind.Relative));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapFoundryResponses_DoesNotDuplicateReadiness_WhenAlreadyMappedAsync()
|
||||
{
|
||||
// Arrange: developer already mapped /readiness with a custom body. The auto-map
|
||||
// must detect the existing route and leave it untouched (no AmbiguousMatchException
|
||||
// at runtime, no override of the developer's response).
|
||||
const string CustomBody = "ready-from-developer";
|
||||
using var host = await BuildTestHostAsync(static app =>
|
||||
{
|
||||
app.MapGet("/readiness", () => Results.Text("ready-from-developer"));
|
||||
app.MapFoundryResponses();
|
||||
});
|
||||
|
||||
// Act
|
||||
var response = await host.GetTestClient().GetAsync(new Uri("/readiness", UriKind.Relative));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.Equal(CustomBody, body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapFoundryResponses_CalledTwice_StillOnlyMapsReadinessOnceAsync()
|
||||
{
|
||||
// Arrange: defensive coverage for callers that map the responses pipeline twice
|
||||
// (e.g. once at the root and once under "openai/v1" in the existing AF samples).
|
||||
using var host = await BuildTestHostAsync(static app =>
|
||||
{
|
||||
app.MapFoundryResponses();
|
||||
app.MapFoundryResponses("openai/v1");
|
||||
});
|
||||
|
||||
// Act + Assert: a single GET /readiness must succeed without ambiguous-match throw.
|
||||
var response = await host.GetTestClient().GetAsync(new Uri("/readiness", UriKind.Relative));
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
private static async Task<IHost> BuildTestHostAsync(Action<WebApplication> configure)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
mockAgent.SetupGet(a => a.Name).Returns("test-agent");
|
||||
builder.Services.AddFoundryResponses(mockAgent.Object);
|
||||
|
||||
var app = builder.Build();
|
||||
configure(app);
|
||||
await app.StartAsync();
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user