diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index e395627bc9..98c364cd53 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -344,6 +344,9 @@ + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/README.md index f0f0be9e74..db0a232412 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/README.md @@ -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. + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/.env.example new file mode 100644 index 0000000000..e4302a5a9a --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/.env.example @@ -0,0 +1,16 @@ +# Azure AI Foundry project endpoint (auto-injected in hosted containers). +AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ + +# 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=... diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/Dockerfile new file mode 100644 index 0000000000..b803098b6a --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/Dockerfile @@ -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"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/Dockerfile.contributor b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/Dockerfile.contributor new file mode 100644 index 0000000000..bbeb4a098b --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/Dockerfile.contributor @@ -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"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/Hosted-Toolbox-AuthPaths.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/Hosted-Toolbox-AuthPaths.csproj new file mode 100644 index 0000000000..da484bb9e1 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/Hosted-Toolbox-AuthPaths.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + enable + enable + false + HostedToolboxAuthPaths + HostedToolboxAuthPaths + $(NoWarn);OPENAI001 + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/Program.cs new file mode 100644 index 0000000000..7bf9baeaf5 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/Program.cs @@ -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 ─────────────────────────────────────────────── + +/// +/// A for local Docker debugging only. +/// Reads a pre-fetched bearer token from the AZURE_BEARER_TOKEN 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 ... +/// +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 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); + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/README.md new file mode 100644 index 0000000000..e1cf78fc24 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/README.md @@ -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 ` 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://.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//resourceGroups//providers/Microsoft.CognitiveServices/accounts/" + +az role assignment create ` + --assignee-object-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//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects/" +$lang = "https://.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 `) | +| `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://.services.ai.azure.com/api/projects/" +$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://.services.ai.azure.com/api/projects/" +$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. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/agent.manifest.yaml new file mode 100644 index 0000000000..77336ea78f --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/agent.manifest.yaml @@ -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: [] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/agent.yaml new file mode 100644 index 0000000000..87cca57bb7 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/agent.yaml @@ -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 diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/Program.cs index 3f6c0a70a7..b06b8f5688 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/Program.cs @@ -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); diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/README.md new file mode 100644 index 0000000000..37d4dd7d28 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/README.md @@ -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. diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxBearerTokenHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxBearerTokenHandler.cs index d345297276..23e2858322 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxBearerTokenHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxBearerTokenHandler.cs @@ -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; /// /// An that: /// -/// Acquires a fresh Azure bearer token (scope: https://cognitiveservices.azure.com/.default) per request. -/// Injects the Foundry-Features header from FOUNDRY_AGENT_TOOLSET_FEATURES when non-empty. +/// Acquires a fresh Azure bearer token (scope: https://ai.azure.com/.default) per request, per tools-integration-spec.md §4. +/// Always injects the mandatory Foundry-Features: Toolboxes=V1Preview header per spec §2, merging any additional flags from FOUNDRY_AGENT_TOOLSET_FEATURES. +/// Propagates W3C trace context (traceparent, tracestate, baggage) from per spec §6.3. /// Retries on HTTP 429, 500, 502, and 503 with exponential back-off (max 3 attempts, per spec §7). /// /// 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 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," 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 CloneRequestAsync( HttpRequestMessage original, CancellationToken cancellationToken) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxHealthCheck.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxHealthCheck.cs new file mode 100644 index 0000000000..e4e297d63b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxHealthCheck.cs @@ -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; + +/// +/// Adapts to the AspNetCore +/// HealthChecks pipeline so the GET /readiness probe (mapped by +/// ) reflects whether +/// pre-registered toolbox connections are usable. Registered automatically by +/// +/// and its overloads. +/// +[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 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(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}'.")); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxOptions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxOptions.cs index 78430f40bf..de3ccbf2ad 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxOptions.cs @@ -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: - /// {FOUNDRY_AGENT_TOOLSET_ENDPOINT}/{toolboxName}/mcp?api-version={ApiVersion} + /// {FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{toolboxName}/mcp?api-version={ApiVersion} + /// per tools-integration-spec.md §2–§3. /// public IList ToolboxNames { get; } = []; /// - /// 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. /// - public string ApiVersion { get; set; } = "2025-05-01-preview"; + public string ApiVersion { get; set; } = "v1"; /// /// 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; /// - /// For testing only: overrides FOUNDRY_AGENT_TOOLSET_ENDPOINT. + /// For testing only: overrides the toolbox proxy base URL (skipping the + /// FOUNDRY_PROJECT_ENDPOINT-derived default). When set, the proxy URL + /// becomes {EndpointOverride}/toolboxes/{toolboxName}/mcp?api-version={ApiVersion}. /// Not part of the public API. /// internal string? EndpointOverride { get; set; } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs index 7a8bc71e02..44b3d123b8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs @@ -24,7 +24,13 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; /// /// /// -/// When FOUNDRY_AGENT_TOOLSET_ENDPOINT is absent the service starts without error and +/// The toolbox proxy base URL is derived from the platform-injected +/// FOUNDRY_PROJECT_ENDPOINT environment variable per tools-integration-spec.md +/// §2–§3. The per-toolbox proxy URL is constructed as +/// {FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{toolboxName}/mcp?api-version={ApiVersion}. +/// +/// +/// When FOUNDRY_PROJECT_ENDPOINT is absent the service starts without error and /// no tools are registered, keeping the container healthy per spec §2. /// /// @@ -56,6 +62,24 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable /// public IReadOnlyList Tools { get; private set; } = []; + /// + /// Gets the startup status of the service. Reflects the outcome of pre-registered + /// toolbox connections opened in ; lazy-opens triggered by + /// per-request markers do not change this value. + /// + /// + /// Consumed by to gate the + /// GET /readiness probe so the Foundry hosted runtime does not start routing + /// traffic to a container whose pre-registered toolbox failed to open at startup. + /// + public FoundryToolboxStartupStatus StartupStatus { get; private set; } = FoundryToolboxStartupStatus.Pending; + + /// + /// Gets the names of pre-registered toolboxes that failed to open during + /// . Empty when startup was successful or has not run yet. + /// + public IReadOnlyList FailedToolboxNames { get; private set; } = []; + /// /// Initializes a new instance of . /// @@ -75,16 +99,24 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable /// 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(); + var failed = new List(); var seen = new HashSet(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; } /// @@ -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)) { diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxStartupStatus.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxStartupStatus.cs new file mode 100644 index 0000000000..821287192d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxStartupStatus.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Outcome of startup. Drives the +/// foundry-toolbox health-check that gates the GET /readiness probe so the +/// Foundry hosted runtime does not start routing traffic before pre-registered toolbox +/// connections are confirmed open (per container-image-spec.md §3.1). +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public enum FoundryToolboxStartupStatus +{ + /// + /// has not run yet. The health-check + /// reports Unhealthy in this state so the platform waits for startup to + /// complete before the first invocation. + /// + Pending = 0, + + /// + /// Startup completed and either every pre-registered toolbox opened successfully or + /// no pre-registered toolboxes were configured. The health-check reports + /// Healthy. + /// + Healthy = 1, + + /// + /// 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 + /// Unhealthy and exposes the failed names in the HealthCheckResult.Data + /// dictionary so operators can diagnose the failure without parsing log output. + /// + Unhealthy = 2, + + /// + /// Neither the FOUNDRY_PROJECT_ENDPOINT nor the AZURE_AI_PROJECT_ENDPOINT + /// environment variable is set. This is normal for local dotnet run flows and the + /// health-check reports Healthy so the container is still routable; toolbox tools + /// will simply not be available. + /// + NoEndpoint = 3, +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj index 71d9af8f71..ec348b2167 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj @@ -13,7 +13,7 @@ true true true - $(NoWarn);OPENAI001;MEAI001;NU1903 + $(NoWarn);OPENAI001;MEAI001;MAAI001;NU1903 false diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs index a0f53b342e..bd61b9f4eb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs @@ -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(_ => FileSystemAgentSessionStore.CreateDefault()); services.TryAddSingleton(); 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 /// /// Each string in is a toolbox name registered in the Foundry /// project. The proxy URL per toolbox is constructed as: - /// {FOUNDRY_AGENT_TOOLSET_ENDPOINT}/{toolboxName}/mcp?api-version=2025-05-01-preview + /// {FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{toolboxName}/mcp?api-version=v1 /// /// - /// When FOUNDRY_AGENT_TOOLSET_ENDPOINT is absent, startup succeeds without error and + /// When FOUNDRY_PROJECT_ENDPOINT is absent, startup succeeds without error and /// no tools are loaded (the container remains healthy per spec §2). /// /// @@ -167,12 +171,61 @@ public static class FoundryHostingExtensions // multiple times will not invoke StartAsync twice on the same singleton. services.AddHostedService(sp => sp.GetRequiredService()); + // 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(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(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(sp), + failureStatus: HealthStatus.Unhealthy, + tags: ["foundry", "toolbox", "readiness"])); + }); + return services; } /// /// Maps the Responses API routes for the agent-framework handler to the endpoint routing pipeline. /// + /// + /// + /// Also maps the Foundry-required GET /readiness health probe to + /// + /// when no /readiness route is already registered. This makes the package + /// spec-compliant in the Foundry hosted runtime (which probes /readiness + /// before accepting any invocation per container-image-spec.md §2; without + /// it every request fails with HTTP 424 session_not_ready) regardless of the + /// host spine the developer chose: + /// + /// + /// Tier 1/2 (AgentHost.CreateBuilder) — the Core SDK + /// already maps /readiness. The duplicate-route guard below skips + /// re-mapping it. + /// Tier 3 (WebApplication.CreateBuilder + + /// AddFoundryResponses + MapFoundryResponses) — the Core SDK + /// does NOT map it. This call covers the gap automatically. + /// + /// + /// Developers can still opt out by registering their own /readiness route + /// before calling MapFoundryResponses; the existing route is detected and + /// preserved. + /// + /// /// The endpoint route builder. /// Optional route prefix (e.g., "/openai/v1"). Default: empty (routes at /responses). /// The endpoint route builder for chaining. @@ -180,9 +233,37 @@ public static class FoundryHostingExtensions { ArgumentNullException.ThrowIfNull(endpoints); endpoints.MapResponsesServer(prefix); + MapReadinessIfMissing(endpoints); return endpoints; } + /// + /// Maps GET /readiness to the AspNetCore HealthChecks pipeline only when no + /// route already serves that path. The duplicate guard scans + /// entries by route pattern, which catches both the + /// SDK-mapped MapHealthChecks("/readiness") path used by + /// AgentHostBuilder and any user-registered app.MapGet("/readiness", ...) + /// route. Idempotent across multiple MapFoundryResponses invocations. + /// + 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); + } + /// /// The ActivitySource name for the Responses hosting pipeline. /// diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs index babfdfcddf..29d57d369a 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs @@ -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) => diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryProjectEndpointEnvFixture.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryProjectEndpointEnvFixture.cs new file mode 100644 index 0000000000..667bc46b59 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryProjectEndpointEnvFixture.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; + +/// +/// xUnit collection that serializes tests mutating the FOUNDRY_PROJECT_ENDPOINT +/// process environment variable. Without this, parallel test execution causes flaky +/// races between tests that set / unset the variable. +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class FoundryProjectEndpointEnvFixture +{ + public const string Name = "FoundryProjectEndpointEnv"; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxBearerTokenHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxBearerTokenHandlerTests.cs index e619445481..eafa3c497a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxBearerTokenHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxBearerTokenHandlerTests.cs @@ -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(); + var credential = new Mock(); + credential + .Setup(c => c.GetTokenAsync(It.IsAny(), It.IsAny())) + .Callback((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 _) => 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 _) => 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] diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxHealthCheckTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxHealthCheckTests.cs new file mode 100644 index 0000000000..7ea5d14e1c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxHealthCheckTests.cs @@ -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()); + 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>(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()); + 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()); + } + + private static HealthCheckContext NewContext(HealthStatus failureStatus) => + new() + { + Registration = new HealthCheckRegistration( + name: "foundry-toolbox", + instance: Mock.Of(), + failureStatus: failureStatus, + tags: null), + }; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxServiceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxServiceTests.cs index cdcdf5ee8e..d8a56adcd6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxServiceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxServiceTests.cs @@ -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( 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()); + + // 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()); + + 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()); + + 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()); + + // 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()); + + await service.StartAsync(CancellationToken.None); + + Assert.Equal(FoundryToolboxStartupStatus.Healthy, service.StartupStatus); + Assert.Empty(service.FailedToolboxNames); + Assert.Empty(service.Tools); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ServiceCollectionExtensionsTests.cs index bcab777af0..f49c669f66 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ServiceCollectionExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ServiceCollectionExtensionsTests.cs @@ -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 BuildTestHostAsync(Action configure) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + var mockAgent = new Mock(); + mockAgent.SetupGet(a => a.Name).Returns("test-agent"); + builder.Services.AddFoundryResponses(mockAgent.Object); + + var app = builder.Build(); + configure(app); + await app.StartAsync(); + return app; + } }