diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index c36bc227d93..f12f29a4b83 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -362,6 +362,7 @@ + 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 index f1e80b09ed6..078b6bc9c81 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/README.md @@ -2,7 +2,7 @@ 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. +Drive the agent across the auth paths with the shared [`Using-Samples/SimpleAgent/`](../Using-Samples/SimpleAgent/) REPL client, pointed at this agent. For the **OAuth user-consent** path (#4 below), use the dedicated [`Using-Samples/Hosted-Toolbox-AuthPaths-Client/`](../Using-Samples/Hosted-Toolbox-AuthPaths-Client/) REPL, which detects the consent request, **prints the consent link** and waits for you to press Enter once you have signed in, then re-sends. It never auto-opens a browser, so it works in headless, SSH, and container shells. ## What this sample teaches @@ -26,6 +26,9 @@ The sample's purpose is to enumerate every authentication path a Foundry toolbox | 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. | +| 4 | **OAuth — per-user consent (delegated)** | Any per-user OAuth-protected MCP target (e.g. delegated Microsoft Graph, a Logic Apps connector) | `OAuth` connection | The first call for a user has no stored token, so the proxy returns `CONSENT_REQUIRED`. The agent surfaces an `oauth_consent_request` with a consent link and marks the response `incomplete`. The user consents out of band; the proxy then stores their delegated token (bound to the user, not the conversation) and performs the on-behalf-of exchange on every subsequent call. | The tool must act **as the end user** against a downstream that requires delegated consent. | + +> **Path #4 needs the OAuth-aware client.** The shared `SimpleAgent/` REPL ignores the consent request and the call simply stays incomplete. Use [`Using-Samples/Hosted-Toolbox-AuthPaths-Client/`](../Using-Samples/Hosted-Toolbox-AuthPaths-Client/) instead — it prints the consent link, waits for you to press Enter after you have signed in, then re-sends the prompt. The user's token never touches the container or the client; consent and the OBO exchange happen entirely between the user, the identity provider, and the toolbox proxy. ## Prerequisites @@ -167,11 +170,16 @@ 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) +Send a test email to myself. # path #4 — OAuth user consent (use the OAuth client) ``` +> Path #4 triggers the consent flow on first use. Run it from [`Using-Samples/Hosted-Toolbox-AuthPaths-Client/`](../Using-Samples/Hosted-Toolbox-AuthPaths-Client/), not `SimpleAgent/`. + ## 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: +`AddFoundryToolboxes` resolves the toolbox at startup by listing its tools via MCP `tools/list`. For **hard** errors this enumeration is **all-or-nothing**: if *any* single tool source fails to enumerate (a bad `allowed_tools` name, a rejected key or Entra token, an unreachable upstream), 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. Get each source enumerating cleanly before deploying. + +**Exception — OAuth consent (path #4) does not brick the container.** When a source fails enumeration purely because it needs per-user OAuth consent (`CONSENT_REQUIRED`), the hosting package keeps the container **healthy and routable**: `/readiness` stays 200 and the consent requirement is surfaced per-request as an `oauth_consent_request` with a consent link. The user consents (via the [`Hosted-Toolbox-AuthPaths-Client/`](../Using-Samples/Hosted-Toolbox-AuthPaths-Client/) REPL), re-sends, and enumeration is retried so the tool becomes available. A *mix* of `CONSENT_REQUIRED` and any non-consent error is still treated as a hard failure (consent alone cannot make enumeration succeed). Symptoms per auth path: | Symptom | Likely cause | |---|---| diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/Hosted-Toolbox-AuthPaths-Client/Hosted-Toolbox-AuthPaths-Client.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/Hosted-Toolbox-AuthPaths-Client/Hosted-Toolbox-AuthPaths-Client.csproj new file mode 100644 index 00000000000..3e2e88134ab --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/Hosted-Toolbox-AuthPaths-Client/Hosted-Toolbox-AuthPaths-Client.csproj @@ -0,0 +1,26 @@ + + + + Exe + net10.0 + enable + enable + false + HostedToolboxAuthPathsClient + hosted-toolbox-authpaths-client + $(NoWarn);NU1605;OPENAI001 + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/Hosted-Toolbox-AuthPaths-Client/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/Hosted-Toolbox-AuthPaths-Client/Program.cs new file mode 100644 index 00000000000..51ac9c3dd5d --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/Hosted-Toolbox-AuthPaths-Client/Program.cs @@ -0,0 +1,312 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Hosted Toolbox Auth Paths — OAuth consent REPL client. +// +// This REPL drives the Hosted-Toolbox-AuthPaths agent and, unlike the plain +// Using-Samples/SimpleAgent client, it understands the OAuth user-consent path. +// +// When a toolbox tool source is fronted by a per-user OAuth connection (for example a +// delegated Microsoft Graph or a Logic Apps connector), the Foundry toolbox proxy cannot +// call the tool until the end user has consented. The hosted agent surfaces that as an +// oauth_consent_request output item carrying a consent link, and marks the response +// incomplete. This is the platform-canonical consent surface (the same item the Foundry +// Bot/Teams and A2A heads render as "open link + resume"), distinct from mcp_approval_request, +// which is the generic approve/deny tool gate. This client: +// 1. Detects the oauth_consent_request and extracts the consent link. +// 2. PRINTS the consent link and waits for the user to press Enter once they have completed +// the OAuth flow out of band. It never auto-opens a browser, so it works in headless, +// SSH, container, and other non-GUI environments. +// 3. RE-SENDS the original prompt on the same session. The proxy now holds the user's +// delegated token, so the retried tool call succeeds. +// +// Important: an OAuth consent request is resumed by RE-SENDING the prompt, NOT by replying +// with a ToolApprovalResponseContent. The consent request records no approval-id mapping on +// the server, so a CreateResponse(...) reply would be rejected. Only function-tool approvals +// (which this client also handles, for completeness) use the CreateResponse path. +// +// Required environment variables: +// AZURE_AI_PROJECT_ENDPOINT - Foundry project endpoint, or the local dev server base +// (e.g. http://localhost:8088/api/projects/local). +// AZURE_AI_AGENT_NAME - The registered server-side agent name +// (default: hosted-toolbox-auth-paths-agent). + +using System.ClientModel.Primitives; +using System.Text.Json; +using Azure.AI.Projects; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry; +using Microsoft.Extensions.AI; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +Uri projectEndpoint = new(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") + ?? Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.")); + +string agentName = Environment.GetEnvironmentVariable("AZURE_AI_AGENT_NAME") + ?? "hosted-toolbox-auth-paths-agent"; + +// Derive the per-agent OpenAI endpoint that hosted Foundry agents require. +Uri agentEndpoint = new($"{projectEndpoint}/agents/{agentName}/endpoint/protocols/openai"); + +var options = new AIProjectClientOptions(); + +if (projectEndpoint.Scheme == "http") +{ + // For local HTTP dev: present HTTPS to satisfy BearerTokenPolicy's TLS check, then swap + // the scheme back to HTTP right before the request hits the wire. + projectEndpoint = new UriBuilder(projectEndpoint) { Scheme = "https" }.Uri; + agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = "https" }.Uri; + options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport); +} + +var aiProjectClient = new AIProjectClient(projectEndpoint, new AzureCliCredential(), options); +FoundryAgent agent = aiProjectClient.AsAIAgent(agentEndpoint); + +AgentSession session = await agent.CreateSessionAsync(); + +Console.ForegroundColor = ConsoleColor.Cyan; +Console.WriteLine($""" + ══════════════════════════════════════════════════════════ + Hosted Toolbox Auth Paths — OAuth consent client + Connected to: {agentEndpoint} + Ask something that needs the OAuth-protected tool to trigger consent. + Type a message or 'quit' to exit. + ══════════════════════════════════════════════════════════ + """); +Console.ResetColor(); +Console.WriteLine(); + +while (true) +{ + Console.ForegroundColor = ConsoleColor.Green; + Console.Write("You> "); + Console.ResetColor(); + + string? input = Console.ReadLine(); + + if (string.IsNullOrWhiteSpace(input)) { continue; } + if (input.Equals("quit", StringComparison.OrdinalIgnoreCase)) { break; } + + try + { + AgentResponse response = await agent.RunAsync(input, session); + + // Resolve any consent or approval requests before showing the final answer. Each + // round-trip either re-sends (OAuth consent) or replies with an approval decision + // (function tools). + while (true) + { + List contents = response.Messages + .SelectMany(m => m.Contents) + .ToList(); + + // An OAuth consent request surfaces as an oauth_consent_request output item carrying a + // consent link. It is resumed by RE-SENDING the prompt (no reply item), because the + // proxy stores the user's delegated token server-side keyed to the user. + List consentUrls = contents + .Select(TryExtractConsentUrl) + .Where(url => url is not null) + .Select(url => url!) + .Distinct(StringComparer.Ordinal) + .ToList(); + + if (consentUrls.Count > 0) + { + Console.ForegroundColor = ConsoleColor.Magenta; + Console.WriteLine(); + Console.WriteLine("The agent needs your OAuth consent before it can call a tool on your behalf."); + Console.WriteLine("Open the link(s) below in any browser and complete the sign-in / consent:"); + foreach (string url in consentUrls) + { + Console.WriteLine(); + Console.WriteLine(url); + } + Console.WriteLine(); + Console.ResetColor(); + + // Wait for the user to finish consent out of band. They can press Enter once done. + // The value is not needed to resume — the proxy stores the delegated token + // server-side keyed to the user. + Console.Write("After completing consent, press Enter to continue... "); + _ = Console.ReadLine(); + + // Re-send the SAME prompt on the SAME session. The proxy now holds the user's + // delegated token, so the retried tool call succeeds. Do NOT CreateResponse here. + response = await agent.RunAsync(input, session); + continue; + } + + // Function-tool approval path (Y/N), included so the client also handles agents + // that mix human-in-the-loop function approvals with OAuth consent. + List approvals = contents + .OfType() + .ToList(); + + if (approvals.Count == 0) + { + break; + } + + List decisions = approvals.ConvertAll(approval => + { + string name = (approval.ToolCall as FunctionCallContent)?.Name ?? "tool"; + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write($"Approve tool call '{name}'? [Y/N] "); + Console.ResetColor(); + bool approved = Console.ReadLine()?.Trim().Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false; + return new ChatMessage(ChatRole.User, [approval.CreateResponse(approved)]); + }); + + response = await agent.RunAsync(decisions, session); + } + + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"Agent> {response}"); + Console.ResetColor(); + } + catch (Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"Error: {ex.Message}"); + Console.ResetColor(); + } + + Console.WriteLine(); +} + +Console.WriteLine("Goodbye!"); + +// ── Helpers ───────────────────────────────────────────────────────────────────── + +// Extracts an OAuth consent link from a response content, or returns null when the content is +// not a consent request. The canonical surface is an `oauth_consent_request` output item, which +// the high-level client exposes through AIContent.RawRepresentation (its `ConsentLink` field +// carries the URL). For resilience this also handles a consent URL surfaced inside a +// ToolApprovalRequestContent payload (the older mcp_approval_request shape). Plain +// human-in-the-loop function approvals carry no URL and return null. +static string? TryExtractConsentUrl(AIContent content) +{ + // 1) Canonical: oauth_consent_request output item via RawRepresentation. + if (TryGetConsentLinkFromRaw(content.RawRepresentation) is { } fromRaw) + { + return fromRaw; + } + + // 2) Back-compat: a consent URL carried in an approval request's arguments. + if (content is ToolApprovalRequestContent approval) + { + return TryExtractConsentUrlFromApproval(approval); + } + + return null; +} + +// Reads a consent link from a raw oauth_consent_request item. The high-level client surfaces this +// item as a base AIContent whose RawRepresentation is an SDK response item: in the typed case it +// exposes a `ConsentLink` member, but the OpenAI Responses client parses the (non-OpenAI) +// oauth_consent_request as an *unknown* item, so the link only lives in the item's JSON. We try the +// typed member first, then fall back to serializing the persistable model and reading `consent_link`. +static string? TryGetConsentLinkFromRaw(object? raw) +{ + if (raw is null) + { + return null; + } + + // Fast path: a typed consent item exposes ConsentLink directly (Uri or string). + switch (raw.GetType().GetProperty("ConsentLink")?.GetValue(raw)) + { + case Uri uri when LooksLikeUrl(uri.AbsoluteUri): + return uri.AbsoluteUri; + case string s when LooksLikeUrl(s): + return s; + } + + // General path: serialize the unknown response item back to JSON and read the wire fields. + try + { + BinaryData json = ModelReaderWriter.Write(raw, new ModelReaderWriterOptions("J")); + using JsonDocument doc = JsonDocument.Parse(json); + JsonElement root = doc.RootElement; + if (root.ValueKind == JsonValueKind.Object + && root.TryGetProperty("type", out JsonElement typeProp) + && typeProp.GetString() == "oauth_consent_request" + && root.TryGetProperty("consent_link", out JsonElement linkProp) + && linkProp.GetString() is string link + && LooksLikeUrl(link)) + { + return link; + } + } + catch + { + // Not a persistable model, or no consent_link present — fall through. + } + + return null; +} + +// Scans an approval request's tool-call arguments for a consent URL (older wire shape, where a +// toolbox OAuth consent was surfaced as an mcp_approval_request carrying a consent_url argument). +static string? TryExtractConsentUrlFromApproval(ToolApprovalRequestContent approval) +{ + IDictionary? arguments = approval.ToolCall switch + { + McpServerToolCallContent mcpCall => mcpCall.Arguments, + FunctionCallContent functionCall => functionCall.Arguments, + _ => null, + }; + + // Only the explicit consent_url key counts (the legacy mcp_approval_request consent shape). + // We deliberately do NOT scan arbitrary argument values for URLs, so a normal function-tool + // approval that happens to carry a URL argument is never misread as an OAuth consent request. + if (arguments is null || !arguments.TryGetValue("consent_url", out object? value)) + { + return null; + } + + return value switch + { + string s when LooksLikeUrl(s) => s, + JsonElement { ValueKind: JsonValueKind.String } element + when element.GetString() is string elementString && LooksLikeUrl(elementString) => elementString, + _ => null, + }; +} + +static bool LooksLikeUrl(string value) => + value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || value.StartsWith("https://", StringComparison.OrdinalIgnoreCase); + +/// +/// For Local Development Only. +/// Rewrites HTTPS URIs to HTTP right before transport, allowing AIProjectClient to target a +/// local HTTP dev server while satisfying BearerTokenPolicy's TLS check. +/// +internal sealed class HttpSchemeRewritePolicy : PipelinePolicy +{ + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + RewriteScheme(message); + ProcessNext(message, pipeline, currentIndex); + } + + public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + RewriteScheme(message); + await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false); + } + + private static void RewriteScheme(PipelineMessage message) + { + var uri = message.Request.Uri!; + if (uri.Scheme == Uri.UriSchemeHttps) + { + message.Request.Uri = new UriBuilder(uri) { Scheme = "http" }.Uri; + } + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/Hosted-Toolbox-AuthPaths-Client/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/Hosted-Toolbox-AuthPaths-Client/README.md new file mode 100644 index 00000000000..50c8c9248c2 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/Hosted-Toolbox-AuthPaths-Client/README.md @@ -0,0 +1,76 @@ +# Hosted Toolbox Auth Paths — OAuth consent client + +A REPL client for the [`Hosted-Toolbox-AuthPaths/`](../../Hosted-Toolbox-AuthPaths/) agent that understands the **OAuth user-consent** path. + +The plain [`SimpleAgent/`](../SimpleAgent/) REPL is enough for the key-based, agent-identity, and inline-`Authorization` tools. It is **not** enough for a tool fronted by a **per-user OAuth connection** (for example a delegated Microsoft Graph connector or a Logic Apps connector), because that tool cannot run until the end user has consented. This client handles that flow. + +## What it does + +When a toolbox tool source needs the user's delegated token, the hosted agent surfaces an `oauth_consent_request` output item that carries a **consent link** and marks the response `incomplete`. This client: + +1. Detects the `oauth_consent_request` and extracts the consent link. +2. **Prints the consent link** so you can open it in any browser and complete the OAuth flow out of band. It never auto-opens a browser, so it works in headless, SSH, and container shells. +3. Waits for you to press Enter, then **re-sends the original prompt on the same session**. The toolbox proxy now holds your delegated token, so the retried tool call succeeds. + +> **Why re-send instead of replying with an approval?** An OAuth consent request records no approval-id mapping on the server, so a `ToolApprovalResponseContent` (`CreateResponse(...)`) reply would be rejected. The user's token lives on the proxy after consent, so simply re-sending the same prompt resumes the call. Function-tool approvals are different: those *do* use `CreateResponse(...)`, and this client handles them too for completeness. + +## How the consent flows (no token ever touches this client) + +```mermaid +sequenceDiagram + actor You + participant Client + participant Agent as Hosted Agent + participant Proxy as Toolbox Proxy + participant IdP as Identity Provider + participant Target as Target Service + + You->>Client: prompt + Client->>Agent: run prompt + Agent->>Proxy: call tool + Proxy-->>Agent: CONSENT_REQUIRED (no token yet) + Agent-->>Client: oauth_consent_request (consent link) + incomplete + Client-->>You: print consent link + You->>IdP: consent in browser + IdP->>Proxy: token (stored, bound to you) + You->>Client: press Enter + Client->>Agent: re-send same prompt + Agent->>Proxy: call tool + Proxy->>Target: OBO token + Target-->>Proxy: result + Proxy-->>Agent: result + Agent-->>Client: result + Client-->>You: result +``` + +The client never sees the user's token. Consent and the on-behalf-of token exchange happen entirely between the user, the identity provider, and the toolbox proxy. + +## Prerequisites + +- The [`Hosted-Toolbox-AuthPaths/`](../../Hosted-Toolbox-AuthPaths/) agent running (locally or deployed) with at least one toolbox tool source configured for a per-user OAuth connection. See that sample's README, **Auth path #4 (OAuth user consent)**. +- `az login` so the client can mint a bearer token to reach the agent endpoint. + +## Run + +```powershell +cd Hosted-Toolbox-AuthPaths-Client + +# Against the local dev server (the {project} segment is a wildcard the server ignores): +$env:AZURE_AI_PROJECT_ENDPOINT = "http://localhost:8088/api/projects/local" +$env:AZURE_AI_AGENT_NAME = "hosted-toolbox-auth-paths-agent" + +# Or against a deployed agent: +# $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 +``` + +Then ask something that needs the OAuth-protected tool. When the consent prompt appears, the client prints the consent link. Open it in any browser, complete sign-in, then press Enter and the client re-sends automatically. + +## Environment variables + +| Variable | Required | Default | Notes | +|---|---|---|---| +| `AZURE_AI_PROJECT_ENDPOINT` | yes | — | Foundry project endpoint, or the local dev server base. `FOUNDRY_PROJECT_ENDPOINT` is read as a fallback. | +| `AZURE_AI_AGENT_NAME` | no | `hosted-toolbox-auth-paths-agent` | Registered server-side agent name. | diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index 1bbbb3c09bd..f461779c5dd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; +using System.Security.Cryptography; using System.Threading; using Azure.AI.AgentServer.Responses; using Azure.AI.AgentServer.Responses.Models; @@ -162,6 +163,39 @@ public override async IAsyncEnumerable CreateAsync( // in both the pre-registered list and the per-request markers. if (this._toolboxService is not null) { + // Retry any pre-registered toolbox that was deferred at startup because it could not be + // enumerated without a per-user context (non-consent failure). The request's egress now + // carries the platform-injected per-user isolation key, so a delegated tool source can + // enumerate as the user — or report that it needs OAuth consent, which is then surfaced + // by ResolvePendingConsentsAsync below. + await this._toolboxService + .RetryDeferredToolboxesAsync(cancellationToken) + .ConfigureAwait(false); + + // Resolve any pre-registered toolbox that was awaiting user OAuth consent at startup + // (CONSENT_REQUIRED at tools/list time). If consent is still outstanding, surface it to + // the caller as an oauth_consent_request and stop: the user completes consent out of band, + // then re-sends the request, at which point enumeration succeeds and the tools appear. + var pendingConsents = await this._toolboxService + .ResolvePendingConsentsAsync(cancellationToken) + .ConfigureAwait(false); + if (pendingConsents.Count > 0) + { + foreach (var consent in pendingConsents) + { + foreach (var consentEvent in EmitOAuthConsentRequest( + stream, + consent.ToolName, + consent.ConsentUrl)) + { + yield return consentEvent; + } + } + + yield return stream.EmitIncomplete(reason: null); + yield break; + } + List? toolsToAdd = null; if (this._toolboxService.Tools.Count > 0) @@ -172,6 +206,7 @@ public override async IAsyncEnumerable CreateAsync( var markers = InputConverter.ReadMcpToolboxMarkers(request); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); string? resolutionError = null; + List? markerConsents = null; foreach (var (name, version) in markers) { @@ -180,10 +215,10 @@ public override async IAsyncEnumerable CreateAsync( continue; } - IReadOnlyList? toolboxTools = null; + FoundryToolboxService.ToolboxResolution resolution; try { - toolboxTools = await this._toolboxService + resolution = await this._toolboxService .GetToolboxToolsAsync(name, version, cancellationToken) .ConfigureAwait(false); } @@ -202,8 +237,17 @@ public override async IAsyncEnumerable CreateAsync( break; } + // The marker hit CONSENT_REQUIRED: collect its consent requirement (request-scoped) + // and keep resolving the other markers so we can surface every outstanding consent at + // once. This toolbox contributes no tools to this turn. + if (resolution.Consents.Count > 0) + { + (markerConsents ??= []).AddRange(resolution.Consents); + continue; + } + toolsToAdd ??= []; - foreach (var t in toolboxTools) + foreach (var t in resolution.Tools) { if (!toolsToAdd.Contains(t)) { @@ -218,6 +262,27 @@ public override async IAsyncEnumerable CreateAsync( yield break; } + // A lazy / per-request marker that needs OAuth consent is surfaced as an + // oauth_consent_request and stops this turn, instead of silently running without that + // toolbox. The consent is scoped to this request (it was returned by GetToolboxToolsAsync, + // not recorded globally), so it cannot leak onto a request that did not reference the marker. + if (markerConsents is { Count: > 0 }) + { + foreach (var consent in markerConsents) + { + foreach (var consentEvent in EmitOAuthConsentRequest( + stream, + consent.ToolName, + consent.ConsentUrl)) + { + yield return consentEvent; + } + } + + yield return stream.EmitIncomplete(reason: null); + yield break; + } + if (toolsToAdd?.Count > 0) { chatOptions.Tools = [.. chatOptions.Tools ?? [], .. toolsToAdd]; @@ -286,13 +351,13 @@ public override async IAsyncEnumerable CreateAsync( if (consentInfo is not null) { - // Emit mcp_approval_request output item + incomplete for the consent URL. - foreach (var approvalEvent in stream.OutputItemMcpApprovalRequest( - consentInfo.ToolboxName, + // Emit oauth_consent_request output item + incomplete for the consent URL. + foreach (var consentEvent in EmitOAuthConsentRequest( + stream, consentInfo.ToolName, consentInfo.ConsentUrl)) { - yield return approvalEvent; + yield return consentEvent; } yield return stream.EmitIncomplete(reason: null); @@ -334,6 +399,45 @@ public override async IAsyncEnumerable CreateAsync( } } + /// + /// Emits an oauth_consent_request output item (output_item.added → + /// output_item.done) carrying the toolbox OAuth consent link. + /// + /// + /// This is the canonical platform surface for a per-user OAuth/toolbox consent prompt: the + /// Foundry platform heads (Bot/Teams, A2A) natively render an + /// as an "open consent link + resume" experience, and the item round-trips through history. It is + /// distinct from mcp_approval_request, which is the generic approve/deny tool gate that + /// expects an approval response. OAuth consent needs no reply: the user signs in out of band and + /// re-sends the request. This mirrors the Python oauth_consent_request emission for parity. + /// + /// The response event stream to emit on. + /// The tool source / server label that requires consent. + /// The OAuth consent URL the user must visit. + /// An enumerable of events: output_item.addedoutput_item.done. + internal static IEnumerable EmitOAuthConsentRequest( + ResponseEventStream stream, + string serverLabel, + string consentUrl) + { + var item = new OAuthConsentRequestOutputItem(NewOAuthConsentItemId(), consentUrl, serverLabel); + var builder = stream.AddOutputItem(item.Id); + yield return builder.EmitAdded(item); + yield return builder.EmitDone(item); + } + + /// + /// Generates a wire-format-valid item id for an oauth_consent_request output item. + /// The Responses Server SDK requires ids of the shape {prefix}_{50-char-body}; we use the + /// oacr prefix (matching the Python emission) with 25 random bytes rendered as 50 hex chars. + /// + private static string NewOAuthConsentItemId() + { + Span bytes = stackalloc byte[25]; + RandomNumberGenerator.Fill(bytes); + return "oacr_" + Convert.ToHexString(bytes); + } + /// /// Resolves an from the request. /// Tries agent.name first, then falls back to metadata["entity_id"]. diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxHealthCheck.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxHealthCheck.cs index e4e297d63b2..019366fb501 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxHealthCheck.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxHealthCheck.cs @@ -42,6 +42,20 @@ public Task CheckHealthAsync(HealthCheckContext context, Canc 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.ConsentRequired: + // Report Healthy so the container stays routable. The consent requirement is + // surfaced to the caller per-request as an oauth_consent_request; enumeration is + // retried once the user has consented. Do not include consent URLs here. + return Task.FromResult(HealthCheckResult.Healthy( + description: $"Foundry toolbox: {this._toolboxService.ConsentRequiredToolboxNames.Count} pre-registered toolbox(es) awaiting user OAuth consent.")); + + case FoundryToolboxStartupStatus.Degraded: + // Report Healthy so the container stays routable. The toolbox could not be enumerated + // at startup (no per-user context) and is retried per-request, where the platform + // injects the per-user isolation key on egress. + return Task.FromResult(HealthCheckResult.Healthy( + description: $"Foundry toolbox: {this._toolboxService.DeferredToolboxNames.Count} pre-registered toolbox(es) deferred to per-request resolution.")); + case FoundryToolboxStartupStatus.Pending: return Task.FromResult(new HealthCheckResult( status: context.Registration.FailureStatus, diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs index 44b3d123b8e..bb6c50f9e63 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -13,6 +14,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.Shared.DiagnosticIds; +using ModelContextProtocol; using ModelContextProtocol.Client; namespace Microsoft.Agents.AI.Foundry.Hosting; @@ -49,6 +51,8 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable private readonly ILogger _logger; private readonly Dictionary _toolboxes = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _pendingConsents = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _deferredToolboxNames = new(StringComparer.OrdinalIgnoreCase); private readonly SemaphoreSlim _lazyOpenLock = new(1, 1); private string? _resolvedEndpoint; @@ -80,6 +84,25 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable /// public IReadOnlyList FailedToolboxNames { get; private set; } = []; + /// + /// Gets the names of pre-registered toolboxes whose tools could not be enumerated at + /// startup because a tool source requires user OAuth consent. Such toolboxes keep the + /// container routable (see ); the + /// consent requirement is surfaced per-request and enumeration is retried via + /// once the user has consented. + /// + public IReadOnlyList ConsentRequiredToolboxNames { get; private set; } = []; + + /// + /// Gets the names of pre-registered toolboxes that could not be enumerated at startup due to a + /// non-consent error (for example, a tool source that requires a per-user delegated identity + /// which is only available on a user request's egress). Such toolboxes keep the container + /// routable (see ) and are retried per-request + /// via , where the platform-injected per-user isolation + /// key is present on the toolbox proxy egress. + /// + public IReadOnlyList DeferredToolboxNames { get; private set; } = []; + /// /// Initializes a new instance of . /// @@ -96,6 +119,13 @@ public FoundryToolboxService( this._logger = logger ?? NullLogger.Instance; } + /// + /// Test-only seam for the network-doing toolbox-open step. When set, + /// delegates to this instead of connecting to the live MCP proxy, so the consent/tools resolution + /// and request-scoping logic can be unit-tested deterministically. Never set in production. + /// + internal Func>? ToolboxOpener { get; set; } + /// public async Task StartAsync(CancellationToken cancellationToken) { @@ -130,7 +160,7 @@ public async Task StartAsync(CancellationToken cancellationToken) } var allTools = new List(); - var failed = new List(); + var deferred = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var toolboxName in this._options.ToolboxNames) @@ -142,29 +172,238 @@ public async Task StartAsync(CancellationToken cancellationToken) try { - var cached = await this.OpenToolboxAsync(toolboxName, version: null, cancellationToken).ConfigureAwait(false); + var result = await this.OpenToolboxAsync(toolboxName, version: null, cancellationToken).ConfigureAwait(false); + if (result.Consents is { } consents) + { + // The toolbox could not enumerate because a tool source needs user OAuth consent. + // Keep the container routable: record the requirement so the handler can surface it + // per-request, and retry enumeration once the user has consented. + this._pendingConsents[toolboxName] = consents; + + if (this._logger.IsEnabled(LogLevel.Information)) + { + this._logger.LogInformation( + "Toolbox '{ToolboxName}' requires user OAuth consent before its tools can be enumerated. " + + "The container stays ready; the consent prompt is surfaced to the caller per-request.", + toolboxName); + } + + continue; + } + + var cached = result.Cached!; this._toolboxes[toolboxName] = cached; allTools.AddRange(cached.Tools); } catch (Exception ex) when (ex is not OperationCanceledException) { - if (this._logger.IsEnabled(LogLevel.Error)) + // A non-consent failure at startup is often not permanent: the toolbox may require a + // per-user (delegated) identity that is only present on a user request's egress, + // where the platform stamps the per-user isolation key. Startup enumeration runs as + // the container's managed identity with no user context, so a delegated-only tool + // source (for example a Microsoft Graph / Agent365 connection) cannot list its tools + // yet. Rather than failing readiness and bricking the container, keep it routable and + // defer the toolbox: it is retried per-request via RetryDeferredToolboxesAsync, where + // the user context is available and enumeration can succeed (or surface consent). + if (this._logger.IsEnabled(LogLevel.Warning)) { - this._logger.LogError( + this._logger.LogWarning( ex, - "Failed to connect to toolbox '{ToolboxName}'. Tools from this toolbox will not be available.", + "Toolbox '{ToolboxName}' could not be enumerated at startup; deferring it to per-request resolution. " + + "The container stays ready and the toolbox is retried on the next request when the per-user context is available.", toolboxName); } - failed.Add(toolboxName); + deferred.Add(toolboxName); } } this.Tools = allTools; - this.FailedToolboxNames = failed; - this.StartupStatus = failed.Count == 0 - ? FoundryToolboxStartupStatus.Healthy - : FoundryToolboxStartupStatus.Unhealthy; + foreach (var name in deferred) + { + this._deferredToolboxNames.Add(name); + } + + this.FailedToolboxNames = []; + this.RecomputeStatus(); + } + + /// + /// Recomputes and refreshes + /// and from the current failed, pending-consent and deferred + /// sets. This is the single point that derives the public consent/deferred snapshots from + /// _pendingConsents and _deferredToolboxNames, so every mutation of those sets only + /// needs to call this method. A hard failure dominates; otherwise an outstanding consent + /// requirement keeps the container routable via + /// ; a deferred toolbox keeps it routable + /// via ; otherwise Healthy. + /// + private void RecomputeStatus() + { + this.ConsentRequiredToolboxNames = [.. this._pendingConsents.Keys]; + this.DeferredToolboxNames = [.. this._deferredToolboxNames]; + + this.StartupStatus = this.FailedToolboxNames.Count > 0 + ? FoundryToolboxStartupStatus.Unhealthy + : this._pendingConsents.Count > 0 + ? FoundryToolboxStartupStatus.ConsentRequired + : this._deferredToolboxNames.Count > 0 + ? FoundryToolboxStartupStatus.Degraded + : FoundryToolboxStartupStatus.Healthy; + } + + /// + /// Retries enumeration for any pre-registered toolbox that was awaiting user OAuth consent at + /// startup. Call this at the start of request handling: once the user has completed consent + /// out of band, the proxy holds a valid token and tools/list now succeeds, so the + /// toolbox's tools become available and are appended to . + /// + /// The request cancellation token. + /// + /// The consent requirements that are still outstanding (one per consent-gated tool source). + /// Empty when nothing is pending or every pending toolbox resolved. When non-empty, the caller + /// should surface each entry as an oauth_consent_request and stop, so the user can + /// complete consent and re-send the request. + /// + internal async ValueTask> ResolvePendingConsentsAsync(CancellationToken cancellationToken) + { + // Fast path: nothing awaiting consent. + if (this.ConsentRequiredToolboxNames.Count == 0) + { + return []; + } + + await this._lazyOpenLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (this._pendingConsents.Count == 0) + { + return []; + } + + var stillPending = new List(); + var resolvedTools = new List(); + + foreach (var toolboxName in new List(this._pendingConsents.Keys)) + { + try + { + var result = await this.OpenToolboxAsync(toolboxName, version: null, cancellationToken).ConfigureAwait(false); + if (result.Consents is { } consents) + { + // Still gated: refresh the consent info (the URL may rotate) and surface it. + this._pendingConsents[toolboxName] = consents; + stillPending.AddRange(consents); + continue; + } + + var cached = result.Cached!; + this._toolboxes[toolboxName] = cached; + resolvedTools.AddRange(cached.Tools); + this._pendingConsents.Remove(toolboxName); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Transient/other error: leave the toolbox pending so a later request retries. + // Do not surface a consent prompt (we have no URL); proceed without these tools. + if (this._logger.IsEnabled(LogLevel.Warning)) + { + this._logger.LogWarning( + ex, + "Retry of consent-gated toolbox '{ToolboxName}' failed; it remains pending.", + toolboxName); + } + } + } + + if (resolvedTools.Count > 0) + { + this.Tools = [.. this.Tools, .. resolvedTools]; + } + + this.RecomputeStatus(); + + return stillPending; + } + finally + { + this._lazyOpenLock.Release(); + } + } + + /// + /// Retries enumeration for any pre-registered toolbox that could not be opened at startup due to + /// a non-consent error (recorded in ). Call this at the start of + /// request handling, before : the request's egress carries + /// the platform-injected per-user isolation key, so a toolbox that needs a delegated user identity + /// (for example a Microsoft Graph / Agent365 connection) can now enumerate as that user. On success + /// the toolbox's tools are appended to ; if the proxy now reports the source needs + /// user OAuth consent, the toolbox is moved to the pending-consent set so the caller surfaces the + /// consent prompt; if it still fails, it stays deferred and is retried on a later request. + /// + /// The request cancellation token. + internal async ValueTask RetryDeferredToolboxesAsync(CancellationToken cancellationToken) + { + // Fast path: nothing deferred. + if (this._deferredToolboxNames.Count == 0) + { + return; + } + + await this._lazyOpenLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (this._deferredToolboxNames.Count == 0) + { + return; + } + + var resolvedTools = new List(); + + foreach (var toolboxName in new List(this._deferredToolboxNames)) + { + try + { + var result = await this.OpenToolboxAsync(toolboxName, version: null, cancellationToken).ConfigureAwait(false); + if (result.Consents is { } consents) + { + // With the per-user context now present, the proxy reports the tool source + // needs user OAuth consent. Move it to the pending-consent set (the handler + // surfaces the prompt) and drop it from the deferred set. + this._pendingConsents[toolboxName] = consents; + this._deferredToolboxNames.Remove(toolboxName); + continue; + } + + var cached = result.Cached!; + this._toolboxes[toolboxName] = cached; + resolvedTools.AddRange(cached.Tools); + this._deferredToolboxNames.Remove(toolboxName); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Still failing — keep it deferred and retry on a later request. + if (this._logger.IsEnabled(LogLevel.Warning)) + { + this._logger.LogWarning( + ex, + "Retry of deferred toolbox '{ToolboxName}' failed; it remains deferred.", + toolboxName); + } + } + } + + if (resolvedTools.Count > 0) + { + this.Tools = [.. this.Tools, .. resolvedTools]; + } + + this.RecomputeStatus(); + } + finally + { + this._lazyOpenLock.Release(); + } } /// @@ -179,11 +418,19 @@ public async Task StartAsync(CancellationToken cancellationToken) /// but does not affect the proxy URL used to connect to the toolbox. /// /// The request cancellation token. + /// + /// A request-scoped : its + /// carry the resolved tools, or its carry the OAuth + /// consent requirements the caller must surface for this request. The consent requirement is + /// returned to the caller rather than recorded in the container-global pending-consent state, so a + /// marker referenced by one request can never inject tools into — or raise a consent prompt on — a + /// request that did not reference it. It also does not affect . + /// /// /// Thrown when the toolbox is not pre-registered and /// is , or when the toolbox endpoint is not configured. /// - public async ValueTask> GetToolboxToolsAsync( + internal async ValueTask GetToolboxToolsAsync( string toolboxName, string? version, CancellationToken cancellationToken) @@ -192,10 +439,10 @@ public async ValueTask> GetToolboxToolsAsync( if (this._toolboxes.TryGetValue(toolboxName, out var cached)) { - return cached.Tools; + return new ToolboxResolution(cached.Tools, []); } - if (this._options.StrictMode) + if (this._options.StrictMode && !this._options.ToolboxNames.Contains(toolboxName, StringComparer.OrdinalIgnoreCase)) { throw new InvalidOperationException( $"Toolbox '{toolboxName}' is not pre-registered via AddFoundryToolboxes(...). " + @@ -214,12 +461,23 @@ public async ValueTask> GetToolboxToolsAsync( // Double-check after acquiring the lock to avoid duplicate opens under concurrency. if (this._toolboxes.TryGetValue(toolboxName, out cached)) { - return cached.Tools; + return new ToolboxResolution(cached.Tools, []); + } + + var result = await this.OpenToolboxAsync(toolboxName, version, cancellationToken).ConfigureAwait(false); + if (result.Consents is { } pendingConsents) + { + // The toolbox needs user OAuth consent before its tools can be enumerated. Return the + // consent requirement to the caller so it surfaces an oauth_consent_request for THIS + // request only. Deliberately not recorded in the container-global _pendingConsents + // (that set is for pre-registered toolboxes), so it neither flips StartupStatus nor + // leaks into other requests. + return new ToolboxResolution([], pendingConsents); } - cached = await this.OpenToolboxAsync(toolboxName, version, cancellationToken).ConfigureAwait(false); + cached = result.Cached!; this._toolboxes[toolboxName] = cached; - return cached.Tools; + return new ToolboxResolution(cached.Tools, []); } finally { @@ -227,11 +485,19 @@ public async ValueTask> GetToolboxToolsAsync( } } - private async Task OpenToolboxAsync( + private async Task OpenToolboxAsync( string toolboxName, string? version, CancellationToken cancellationToken) { + // Test seam: when set, the open behavior (the only part that does real network I/O via the + // MCP transport) is supplied by the test so the consent/tools resolution logic can be + // exercised without a live toolbox proxy. Never set in production. + if (this.ToolboxOpener is { } opener) + { + return await opener(toolboxName, version, cancellationToken).ConfigureAwait(false); + } + var proxyUrl = $"{this._resolvedEndpoint!}/toolboxes/{toolboxName}/mcp?api-version={this._options.ApiVersion}"; if (this._logger.IsEnabled(LogLevel.Information)) @@ -239,6 +505,9 @@ private async Task OpenToolboxAsync( this._logger.LogInformation("Connecting to toolbox '{ToolboxName}' at {ProxyUrl}.", toolboxName, proxyUrl); } + // Build the endpoint URI before allocating the HttpClient so a malformed URL cannot leak it. + var endpoint = new Uri(proxyUrl); + var handler = new FoundryToolboxBearerTokenHandler(this._credential, this._featuresHeader) { InnerHandler = new HttpClientHandler() @@ -248,7 +517,7 @@ private async Task OpenToolboxAsync( var transportOptions = new HttpClientTransportOptions { - Endpoint = new Uri(proxyUrl), + Endpoint = endpoint, Name = toolboxName, }; @@ -263,12 +532,44 @@ private async Task OpenToolboxAsync( } }; - var client = await McpClient.CreateAsync( - transport, - clientOptions, - cancellationToken: cancellationToken).ConfigureAwait(false); + // McpClient.CreateAsync runs the MCP initialize handshake and can throw for an unreachable + // proxy (the deferred-toolbox case, retried per request). Keep it inside the try so the + // HttpClient is always disposed on failure rather than leaking a socket on every retry. + McpClient? client = null; + IList mcpTools; + try + { + client = await McpClient.CreateAsync( + transport, + clientOptions, + cancellationToken: cancellationToken).ConfigureAwait(false); + + mcpTools = await client.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + } + catch (McpProtocolException ex) when ( + ToolboxConsentParser.TryParseConsentRequired(toolboxName, ex.Message, out var consents)) + { + // A tool source needs user OAuth consent before it can be enumerated. Dispose the + // half-open client and signal the caller, which keeps the container routable and + // surfaces the consent prompt per-request instead of failing readiness. + if (client is not null) + { + await client.DisposeAsync().ConfigureAwait(false); + } + + httpClient.Dispose(); + return new ToolboxOpenResult(Cached: null, Consents: consents); + } + catch + { + if (client is not null) + { + await client.DisposeAsync().ConfigureAwait(false); + } - var mcpTools = await client.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + httpClient.Dispose(); + throw; + } if (this._logger.IsEnabled(LogLevel.Information)) { @@ -286,7 +587,7 @@ private async Task OpenToolboxAsync( _ = version; // reserved for future version-specific routing; currently handled server-side by the proxy. - return new CachedToolbox(client, httpClient, wrapped); + return new ToolboxOpenResult(new CachedToolbox(client!, httpClient, wrapped), Consents: null); } /// @@ -297,7 +598,11 @@ public async ValueTask DisposeAsync() { foreach (var cached in this._toolboxes.Values) { - await cached.Client.DisposeAsync().ConfigureAwait(false); + if (cached.Client is not null) + { + await cached.Client.DisposeAsync().ConfigureAwait(false); + } + cached.HttpClient.Dispose(); } @@ -305,5 +610,21 @@ public async ValueTask DisposeAsync() this._lazyOpenLock.Dispose(); } - private sealed record CachedToolbox(McpClient Client, HttpClient HttpClient, IReadOnlyList Tools); + internal sealed record CachedToolbox(McpClient? Client, HttpClient HttpClient, IReadOnlyList Tools); + + /// + /// Request-scoped outcome of resolving a per-request toolbox marker via + /// . Either carries the resolved tools, or + /// carries the OAuth consent requirements the caller must surface for this + /// request. Marker resolution never mutates the container-global pending-consent or tool state, so + /// a marker referenced by one request can never leak into a request that did not ask for it. + /// + internal readonly record struct ToolboxResolution(IReadOnlyList Tools, IReadOnlyList Consents); + + /// + /// Outcome of an attempt. Exactly one of the two values is set: + /// when the toolbox opened and its tools were enumerated, or + /// when enumeration is blocked pending user OAuth consent. + /// + internal sealed record ToolboxOpenResult(CachedToolbox? Cached, IReadOnlyList? Consents); } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxStartupStatus.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxStartupStatus.cs index 821287192d8..438b6af24c3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxStartupStatus.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxStartupStatus.cs @@ -43,4 +43,23 @@ public enum FoundryToolboxStartupStatus /// will simply not be available. /// NoEndpoint = 3, + + /// + /// One or more pre-registered toolboxes could not enumerate their tools because a tool + /// source requires user OAuth consent (the proxy returned CONSENT_REQUIRED at + /// tools/list time). The health-check reports Healthy so the container stays + /// routable: the consent requirement is surfaced to the caller as a per-request + /// oauth_consent_request, and enumeration is retried once the user has consented. + /// + ConsentRequired = 4, + + /// + /// One or more pre-registered toolboxes could not be enumerated at startup due to a non-consent + /// error (for example, a tool source that requires a per-user delegated identity, which is only + /// available on a user request's egress). The health-check reports Healthy so the container + /// stays routable: the toolbox is retried per-request via + /// , where the per-user isolation key + /// is present, and either resolves or surfaces a consent prompt at that point. + /// + Degraded = 5, } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ToolboxConsentParser.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ToolboxConsentParser.cs new file mode 100644 index 00000000000..806a613b2be --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ToolboxConsentParser.cs @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Parses the aggregate tools/list failure that the Foundry Toolboxes proxy returns +/// when one or more tool sources require OAuth consent before they can be enumerated. +/// +/// +/// +/// Unlike the per-tool-call path (JSON-RPC error -32006, handled by +/// ), a consent-gated tool fails at +/// enumeration time. The proxy returns a top-level aggregate error whose message +/// embeds a JSON payload describing each failing source, for example: +/// +/// +/// Request failed (remote): tools/list failed for 1 tool source(s), succeeded for 0 tool source(s) +/// {"errors":[{"name":"send_email","type":"mcp","error":{"code":"CONSENT_REQUIRED","message":"https://.../login?data=..."}}]} +/// +/// +/// The consent URL lives in errors[].error.message. This parser extracts those URLs so +/// can keep the container routable and surface the consent +/// requirement as a per-request oauth_consent_request instead of failing readiness. +/// +/// +internal static class ToolboxConsentParser +{ + private const string ConsentRequiredCode = "CONSENT_REQUIRED"; + + /// + /// Attempts to interpret a tools/list failure message as a pure OAuth-consent + /// requirement. + /// + /// The toolbox whose enumeration failed. + /// The of the MCP protocol exception. + /// + /// On success, one per consent-gated tool source. Empty otherwise. + /// + /// + /// only when the message embeds a parseable errors array and + /// every entry is a CONSENT_REQUIRED error. Returns + /// when no JSON payload is present, when parsing fails, or when any non-consent error is + /// present (in that case consent alone cannot make enumeration succeed, so the caller should + /// treat the failure as a hard error). + /// + public static bool TryParseConsentRequired( + string toolboxName, + string? exceptionMessage, + out IReadOnlyList consents) + { + consents = []; + + if (string.IsNullOrEmpty(exceptionMessage)) + { + return false; + } + + // Fast pre-check: avoid JSON work unless the marker code is present. + if (exceptionMessage!.IndexOf(ConsentRequiredCode, StringComparison.Ordinal) < 0) + { + return false; + } + + int jsonStart = exceptionMessage.IndexOf('{'); + if (jsonStart < 0) + { + return false; + } + + var result = new List(); + try + { + using var document = JsonDocument.Parse(exceptionMessage.AsSpan(jsonStart).ToString()); + if (!document.RootElement.TryGetProperty("errors", out var errors) + || errors.ValueKind != JsonValueKind.Array) + { + return false; + } + + foreach (var error in errors.EnumerateArray()) + { + if (!error.TryGetProperty("error", out var errorBody) + || !errorBody.TryGetProperty("code", out var code) + || code.ValueKind != JsonValueKind.String + || !string.Equals(code.GetString(), ConsentRequiredCode, StringComparison.Ordinal)) + { + // A non-consent error means enumeration stays broken even after consent. + return false; + } + + string? consentUrl = errorBody.TryGetProperty("message", out var message) + && message.ValueKind == JsonValueKind.String + ? message.GetString() + : null; + + if (string.IsNullOrEmpty(consentUrl)) + { + return false; + } + + string toolName = error.TryGetProperty("name", out var name) + && name.ValueKind == JsonValueKind.String + ? name.GetString() ?? toolboxName + : toolboxName; + + result.Add(new McpConsentInfo(toolboxName, toolName, consentUrl!)); + } + } + catch (JsonException) + { + return false; + } + + if (result.Count == 0) + { + return false; + } + + consents = result; + return true; + } +} diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs index 29d57d369a1..a12cbde63ec 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs @@ -34,6 +34,7 @@ "tool-calling" => CreateToolCallingAgent(projectClient, deployment), "tool-calling-approval" => CreateToolCallingApprovalAgent(projectClient, deployment), "mcp-toolbox" => CreateMcpToolboxAgent(projectClient, deployment), + "toolbox-oauth-consent" => CreateToolboxOAuthConsentAgent(projectClient, deployment), "custom-storage" => CreateCustomStorageAgent(projectClient, deployment), "memory" => await CreateMemoryAgentAsync(projectClient, deployment).ConfigureAwait(false), "azure-search-rag" => CreateAzureSearchRagAgent(projectClient, deployment), @@ -52,6 +53,16 @@ builder.Services.AddFoundryResponses(agent); +// toolbox-oauth-consent scenario: pre-register a Foundry toolbox whose tool source is fronted by a +// per-user OAuth connection. IT_TOOLBOX_NAME names that toolbox (the fixture sets it). With the +// startup-deferral fix the container stays routable even though the toolbox cannot enumerate without +// a consented user, and the first user request surfaces an oauth_consent_request. +var consentToolboxName = Environment.GetEnvironmentVariable("IT_TOOLBOX_NAME"); +if (!string.IsNullOrEmpty(consentToolboxName)) +{ + builder.Services.AddFoundryToolboxes(consentToolboxName); +} + var app = builder.Build(); app.MapFoundryResponses(); app.Run(); @@ -93,6 +104,18 @@ static AIAgent CreateMcpToolboxAgent(AIProjectClient client, string deployment) name: "mcp-toolbox-agent", description: "MCP toolbox test agent (placeholder)."); +// toolbox-oauth-consent scenario: a plain agent whose tools come from a pre-registered Foundry +// toolbox (wired via AddFoundryToolboxes from IT_TOOLBOX_NAME). The toolbox's tool source requires +// per-user OAuth consent, so the first request that needs the tool surfaces an oauth_consent_request +// instead of running the tool. +static AIAgent CreateToolboxOAuthConsentAgent(AIProjectClient client, string deployment) => + client.AsAIAgent( + model: deployment, + instructions: "You are an assistant that can act on the user's behalf using OAuth-protected tools. " + + "When the user asks you to do something that needs such a tool, call it.", + name: "toolbox-oauth-consent-agent", + description: "Per-user OAuth toolbox consent test agent."); + static AIAgent CreateCustomStorageAgent(AIProjectClient client, string deployment) => // TODO: substitute custom IResponsesStorageProvider in DI. client.AsAIAgent( diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/ToolboxOAuthConsentHostedAgentFixture.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/ToolboxOAuthConsentHostedAgentFixture.cs new file mode 100644 index 00000000000..bbcafb66e03 --- /dev/null +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/ToolboxOAuthConsentHostedAgentFixture.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; + +namespace Foundry.Hosting.IntegrationTests.Fixtures; + +/// +/// Provisions a hosted agent that runs the test container in IT_SCENARIO=toolbox-oauth-consent +/// mode. The container pre-registers a Foundry toolbox (named by IT_TOOLBOX_NAME) whose tool +/// source is fronted by a per-user OAuth connection. The first request that needs the tool must +/// surface an oauth_consent_request instead of running it. +/// +/// +/// Prerequisite (out of band, per project): a Foundry toolbox named by must +/// exist in the target project and reference a tool source that returns CONSENT_REQUIRED for an +/// unconsented user (for example a delegated GitHub or Microsoft Graph connection). Override the +/// toolbox name with the IT_TOOLBOX_NAME environment variable. See the project README. +/// +public sealed class ToolboxOAuthConsentHostedAgentFixture : HostedAgentFixture +{ + private const string ToolboxNameEnvironmentVariable = "IT_TOOLBOX_NAME"; + private const string DefaultToolboxName = "auth-paths-oauth-toolbox"; + + protected override string ScenarioName => "toolbox-oauth-consent"; + + /// + /// The Foundry toolbox the container pre-registers. Resolved from IT_TOOLBOX_NAME, falling + /// back to a default that exists in the reference project. + /// + public string ToolboxName { get; } = + Environment.GetEnvironmentVariable(ToolboxNameEnvironmentVariable) ?? DefaultToolboxName; + + protected override void ConfigureEnvironment(IDictionary environment) + { + // Pass the toolbox name into the container so Program.cs wires AddFoundryToolboxes(name). + // IT_TOOLBOX_NAME is a non-reserved key (FOUNDRY_*/AGENT_* are forbidden by the platform). + environment[ToolboxNameEnvironmentVariable] = this.ToolboxName; + } +} diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md b/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md index 8beacc870f6..3120e002624 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md @@ -111,6 +111,18 @@ To self-serve the `Search Index Data Reader` grant above, you need `User Access need `Search Index Data Contributor`. These are typically granted once per onboarded engineer and reused for every new IT scenario that needs Search. +### OAuth consent toolbox prerequisite (one time, out of band) + +The `toolbox-oauth-consent` scenario assumes a Foundry **toolbox** named by `IT_TOOLBOX_NAME` +(default `auth-paths-oauth-toolbox`) already exists in the target project and references a tool +source fronted by a **per-user OAuth connection** that returns `CONSENT_REQUIRED` for an +unconsented caller (for example a delegated GitHub or Microsoft Graph connection). The test does +not consent on the caller's behalf; it asserts only that the first invocation surfaces an +`oauth_consent_request` consent link to the consumer and that the container stays routable. See +`dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/README.md` +(auth path #4) for how the toolbox/connection is set up. No automated provisioning script ships +for the toolbox; it is treated as pre-existing project configuration. + ## Building and pushing the test container image The test container source lives at `dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer`. @@ -196,6 +208,7 @@ human-only operation; CI only adds and deletes versions under existing agents. | `ToolCallingHostedAgentFixture` | `tool-calling` | `it-tool-calling` | Server side AIFunction invocation; arguments; multi turn referencing prior tool result. | | `ToolCallingApprovalHostedAgentFixture` | `tool-calling-approval` | `it-tool-calling-approval` | Approval requests raised, approved, denied. | | `McpToolboxHostedAgentFixture` | `mcp-toolbox` | `it-mcp-toolbox` | MCP backed tool invocation against `https://learn.microsoft.com/api/mcp` (placeholder). | +| `ToolboxOAuthConsentHostedAgentFixture` | `toolbox-oauth-consent` | `it-toolbox-oauth-consent` | Per-user OAuth toolbox consent: pre-registers a consent-gated Foundry toolbox (`IT_TOOLBOX_NAME`), invokes the agent, asserts the consumer captures an `oauth_consent_request` consent link (and the container stays routable, no 424). Requires a consent-gated toolbox in the project (see prerequisite below). | | `CustomStorageHostedAgentFixture` | `custom-storage` | `it-custom-storage` | Round trip with custom `IResponsesStorageProvider`; multi turn reads from the custom store (placeholder). | | `AzureSearchRagHostedAgentFixture` | `azure-search-rag` | `it-azure-search-rag` | RAG against a real Azure AI Search index seeded with Contoso Outdoors documents; verifies the model cites the retrieved sources. | | `SessionFilesHostedAgentFixture` | `session-files` | `it-session-files` | End-to-end: upload via `AgentSessionFiles` (alpha) into a pinned `agent_session_id`, invoke the agent, assert it reads the file via the container's `ReadFile` tool. | diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/ToolboxOAuthConsentHostedAgentTests.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/ToolboxOAuthConsentHostedAgentTests.cs new file mode 100644 index 00000000000..9a8d1b92a37 --- /dev/null +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/ToolboxOAuthConsentHostedAgentTests.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel.Primitives; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Foundry.Hosting.IntegrationTests.Fixtures; + +namespace Foundry.Hosting.IntegrationTests; + +/// +/// End-to-end test for the per-user OAuth toolbox consent flow. The hosted container pre-registers a +/// Foundry toolbox whose tool source needs per-user OAuth consent; invoking the agent must surface an +/// oauth_consent_request (carrying a consent link) to the consumer instead of silently running +/// without the tool, and the container must stay routable (no 424) despite the consent-gated toolbox. +/// +[Trait("Category", "FoundryHostedAgents")] +public sealed class ToolboxOAuthConsentHostedAgentTests(ToolboxOAuthConsentHostedAgentFixture fixture) + : IClassFixture +{ + private readonly ToolboxOAuthConsentHostedAgentFixture _fixture = fixture; + + [Fact(Skip = "Pending TestContainer build, a consent-gated toolbox in the IT project, and end to end smoke (step 5).")] + public async Task ToolRequiringConsent_SurfacesOAuthConsentRequestToConsumerAsync() + { + // Arrange: the agent is backed by a pre-registered toolbox whose tool source requires + // per-user OAuth consent (the fixture provisioned it, the container stayed routable). + var agent = this._fixture.Agent; + + // Act: ask for something that needs the OAuth-protected tool. The toolbox proxy returns + // CONSENT_REQUIRED for the (unconsented) caller, which the hosted agent surfaces as an + // oauth_consent_request output item and marks the response incomplete. + var response = await agent.RunAsync( + "Use the OAuth-protected tool to act on my behalf. List my pull requests."); + + // Assert: the consumer captured an oauth_consent_request carrying a usable https consent link. + // The high-level client exposes the (non-OpenAI) consent item as an AIContent whose + // RawRepresentation serializes to the oauth_consent_request wire shape, mirroring how the + // Hosted-Toolbox-AuthPaths REPL client detects it. + var consentLink = response.Messages + .SelectMany(m => m.Contents) + .Select(c => TryGetConsentLink(c.RawRepresentation)) + .FirstOrDefault(link => link is not null); + + Assert.False(string.IsNullOrWhiteSpace(consentLink), + "Expected the response to surface an oauth_consent_request with a consent link."); + Assert.StartsWith("https://", consentLink, StringComparison.OrdinalIgnoreCase); + } + + private static string? TryGetConsentLink(object? raw) + { + if (raw is null) + { + return null; + } + + try + { + BinaryData json = ModelReaderWriter.Write(raw, new ModelReaderWriterOptions("J")); + using JsonDocument doc = JsonDocument.Parse(json); + JsonElement root = doc.RootElement; + if (root.ValueKind == JsonValueKind.Object + && root.TryGetProperty("type", out JsonElement typeProp) + && typeProp.GetString() == "oauth_consent_request" + && root.TryGetProperty("consent_link", out JsonElement linkProp) + && linkProp.GetString() is string link + && !string.IsNullOrWhiteSpace(link)) + { + return link; + } + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException or NotSupportedException or FormatException) + { + // Not a persistable model, or no consent link present — treat as no consent. + } + + return null; + } +} diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 b/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 index 1719fa9ffb5..6500bd3c0bf 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 @@ -44,6 +44,7 @@ $Scenarios = @( 'tool-calling', 'tool-calling-approval', 'mcp-toolbox', + 'toolbox-oauth-consent', 'custom-storage', 'memory', 'azure-search-rag', diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxHealthCheckTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxHealthCheckTests.cs index 7ea5d14e1c7..3f3898dfe5c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxHealthCheckTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxHealthCheckTests.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Azure.Core; @@ -64,12 +63,11 @@ public async Task CheckHealthAsync_NoEndpointStatus_ReturnsHealthyAsync() } [Fact] - public async Task CheckHealthAsync_UnhealthyStatus_ReturnsConfiguredFailureWithFailedNamesAsync() + public async Task CheckHealthAsync_DegradedStatus_StaysRoutableForDeferredToolboxAsync() { - // 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. + // Arrange: a pre-registered toolbox at an unreachable endpoint forces StartAsync to defer + // the toolbox (non-consent failure). The container must stay routable so the toolbox can be + // retried per-request, where the platform injects the per-user isolation key on egress. var options = new FoundryToolboxOptions { EndpointOverride = "http://127.0.0.1:1/unreachable", @@ -84,11 +82,11 @@ public async Task CheckHealthAsync_UnhealthyStatus_ReturnsConfiguredFailureWithF // 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)); + // Assert: deferred toolbox keeps the container Healthy (routable), not bricked. + Assert.Equal(FoundryToolboxStartupStatus.Degraded, service.StartupStatus); + Assert.Equal(HealthStatus.Healthy, result.Status); + Assert.Equal("broken-toolbox", Assert.Single(service.DeferredToolboxNames)); + Assert.Contains("deferred", result.Description, StringComparison.OrdinalIgnoreCase); } [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxMarkerScopingTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxMarkerScopingTests.cs new file mode 100644 index 00000000000..f7e3d13bba9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxMarkerScopingTests.cs @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Options; +using Moq; + +namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; + +/// +/// Verifies that resolving a per-request toolbox marker via +/// is strictly request-scoped: a marker +/// referenced by one request must never leak its tools into — or raise a consent prompt on — a +/// request that did not reference it, and must not flip the container-global startup state. +/// +[Collection(FoundryProjectEndpointEnvFixture.Name)] +public class FoundryToolboxMarkerScopingTests +{ + private static async Task CreateStartedServiceAsync( + Func> opener) + { + // Non-strict, no pre-registered toolboxes: StartAsync only resolves the endpoint (so the + // marker path can run) and reports Healthy. The opener seam replaces the network I/O. + var options = new FoundryToolboxOptions + { + StrictMode = false, + EndpointOverride = "http://127.0.0.1:1/unused", + }; + + var service = new FoundryToolboxService( + Options.Create(options), + Mock.Of()) + { + ToolboxOpener = opener, + }; + + await service.StartAsync(CancellationToken.None); + + // Sanity: a clean start with no pre-registered toolboxes is Healthy and global-empty. + Assert.Equal(FoundryToolboxStartupStatus.Healthy, service.StartupStatus); + Assert.Empty(service.ConsentRequiredToolboxNames); + Assert.Empty(service.Tools); + + return service; + } + + [Fact] + public async Task GetToolboxToolsAsync_MarkerConsent_IsRequestScopedAndDoesNotMutateGlobalStateAsync() + { + // Arrange: the opener reports CONSENT_REQUIRED for every marker. + await using var service = await CreateStartedServiceAsync( + (name, _, _) => Task.FromResult( + new FoundryToolboxService.ToolboxOpenResult( + Cached: null, + Consents: [new McpConsentInfo(name, $"{name}.tool", $"https://consent.example/{name}")]))); + + // Act: request A references marker-a and hits consent. + var resolutionA = await service.GetToolboxToolsAsync("marker-a", version: null, CancellationToken.None); + + // Assert: the consent is returned to THIS caller, with no tools. + Assert.Empty(resolutionA.Tools); + Assert.Single(resolutionA.Consents); + Assert.Equal("https://consent.example/marker-a", resolutionA.Consents[0].ConsentUrl); + + // Assert: container-global state is unchanged. The marker consent did not get recorded in + // ConsentRequiredToolboxNames and did not flip StartupStatus to ConsentRequired, so a request + // with no marker (which reads StartupStatus / Tools) is unaffected. + Assert.Equal(FoundryToolboxStartupStatus.Healthy, service.StartupStatus); + Assert.Empty(service.ConsentRequiredToolboxNames); + Assert.Empty(service.Tools); + + // Act: a different request references marker-b. Its consent must not accumulate globally. + var resolutionB = await service.GetToolboxToolsAsync("marker-b", version: null, CancellationToken.None); + + // Assert: still request-scoped, still no global mutation. + Assert.Single(resolutionB.Consents); + Assert.Equal("https://consent.example/marker-b", resolutionB.Consents[0].ConsentUrl); + Assert.Equal(FoundryToolboxStartupStatus.Healthy, service.StartupStatus); + Assert.Empty(service.ConsentRequiredToolboxNames); + Assert.Empty(service.Tools); + } + + [Fact] + public async Task GetToolboxToolsAsync_MarkerTools_AreReturnedToCallerNotInjectedGloballyAsync() + { + // Arrange: the opener resolves marker-a to one tool; any other marker stays consent-gated. + AITool markerTool = AIFunctionFactory.Create(() => "ok", name: "marker_a_tool"); + + await using var service = await CreateStartedServiceAsync( + (name, _, _) => Task.FromResult( + string.Equals(name, "marker-a", StringComparison.OrdinalIgnoreCase) + ? new FoundryToolboxService.ToolboxOpenResult( + new FoundryToolboxService.CachedToolbox(Client: null, new HttpClient(), [markerTool]), + Consents: null) + : new FoundryToolboxService.ToolboxOpenResult( + Cached: null, + Consents: [new McpConsentInfo(name, $"{name}.tool", $"https://consent.example/{name}")]))); + + // Act: request A references marker-a and resolves its tool. + var resolutionA = await service.GetToolboxToolsAsync("marker-a", version: null, CancellationToken.None); + + // Assert: the tool is returned to THIS caller only. + Assert.Empty(resolutionA.Consents); + Assert.Single(resolutionA.Tools); + Assert.Same(markerTool, resolutionA.Tools[0]); + + // Assert: the resolved marker tool was NOT merged into the service-wide Tools cache, so a + // later request with no marker (which only ever gets _toolboxService.Tools) sees nothing. + Assert.Empty(service.Tools); + + // Act: re-resolving marker-a returns the cached tool (no second open), still request-scoped. + var resolutionAgain = await service.GetToolboxToolsAsync("marker-a", version: null, CancellationToken.None); + + // Assert. + Assert.Single(resolutionAgain.Tools); + Assert.Same(markerTool, resolutionAgain.Tools[0]); + Assert.Empty(service.Tools); + } +} 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 d8a56adcd6f..a6b11413575 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxServiceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxServiceTests.cs @@ -76,8 +76,8 @@ public async Task StartAsync_WithoutEndpoint_LeavesToolsEmptyAsync() 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). + // attempts to open the pre-registered toolbox (verified via DeferredToolboxNames + // recording the attempted name and StartupStatus reflecting the deferral). var saved = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT"); Environment.SetEnvironmentVariable( "FOUNDRY_PROJECT_ENDPOINT", @@ -91,14 +91,15 @@ public async Task StartAsync_AttemptsOpenForPreRegisteredToolboxFromProjectEndpo Mock.Of()); // Act: StartAsync attempts to connect to the invalid endpoint and fails. - // The failure path records FailedToolboxNames; the value confirms the resolver ran. + // The failure path defers the toolbox; the recorded name 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]); + // Assert: open failed but the container stays routable (deferred, not bricked), and + // the deferred name matches — i.e. we attempted the right toolbox. + Assert.Equal(FoundryToolboxStartupStatus.Degraded, service.StartupStatus); + Assert.Empty(service.FailedToolboxNames); + Assert.Single(service.DeferredToolboxNames); + Assert.Equal("my-toolbox", service.DeferredToolboxNames[0]); } finally { @@ -124,11 +125,11 @@ public async Task StartAsync_TrailingSlashOnProjectEndpoint_AttemptsOpenAsync() 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 — + // (host is unreachable), but DeferredToolboxNames 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]); + Assert.Equal(FoundryToolboxStartupStatus.Degraded, service.StartupStatus); + Assert.Single(service.DeferredToolboxNames); + Assert.Equal("tb", service.DeferredToolboxNames[0]); } finally { @@ -158,10 +159,10 @@ public async Task StartAsync_EndpointOverrideWinsOverEnvAsync() 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); + // Override URL is unreachable; we expect Degraded (proving Start did try to open + // a toolbox, i.e. did not fall into the NoEndpoint branch) while staying routable. + Assert.Equal(FoundryToolboxStartupStatus.Degraded, service.StartupStatus); + Assert.Single(service.DeferredToolboxNames); } finally { @@ -173,8 +174,9 @@ public async Task StartAsync_EndpointOverrideWinsOverEnvAsync() 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. + // to throw inside the catch-and-defer path. The service must still complete StartAsync + // (so the host doesn't crash) and keep the container routable, deferring the toolbox to + // per-request resolution rather than failing readiness. var options = new FoundryToolboxOptions { EndpointOverride = "http://127.0.0.1:1/unreachable", @@ -189,9 +191,45 @@ public async Task StartAsync_WithEndpointButFailingToolbox_RecordsFailureAndStay await service.StartAsync(CancellationToken.None); // Assert - Assert.Equal(FoundryToolboxStartupStatus.Unhealthy, service.StartupStatus); - Assert.Single(service.FailedToolboxNames); - Assert.Equal("broken-toolbox", service.FailedToolboxNames[0]); + Assert.Equal(FoundryToolboxStartupStatus.Degraded, service.StartupStatus); + Assert.Empty(service.FailedToolboxNames); + Assert.Single(service.DeferredToolboxNames); + Assert.Equal("broken-toolbox", service.DeferredToolboxNames[0]); + Assert.Empty(service.Tools); + + // A deferred (non-consent) toolbox is not a consent requirement: ConsentRequiredToolboxNames + // must stay empty. RecomputeStatus is the single source that keeps ConsentRequiredToolboxNames + // in sync with the pending-consent set, so they never diverge. + Assert.Empty(service.ConsentRequiredToolboxNames); + } + + [Fact] + public async Task RetryDeferredToolboxesAsync_StillUnreachable_StaysDeferredAndRoutableAsync() + { + // Arrange: a deferred toolbox (open failed at startup against an unreachable endpoint). + // A per-request retry that still cannot reach the proxy must keep the toolbox deferred + // and the container routable (Degraded), never throwing into the request pipeline. + 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); + Assert.Single(service.DeferredToolboxNames); + + // Act: retry while the endpoint is still unreachable. + await service.RetryDeferredToolboxesAsync(CancellationToken.None); + + // Assert: unchanged — still deferred, still routable, no tools injected. + Assert.Equal(FoundryToolboxStartupStatus.Degraded, service.StartupStatus); + Assert.Single(service.DeferredToolboxNames); + Assert.Equal("broken-toolbox", service.DeferredToolboxNames[0]); + Assert.Empty(service.FailedToolboxNames); Assert.Empty(service.Tools); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OAuthConsentEmissionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OAuthConsentEmissionTests.cs new file mode 100644 index 00000000000..8ec80b11e87 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OAuthConsentEmissionTests.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using Azure.AI.AgentServer.Responses; +using Azure.AI.AgentServer.Responses.Models; +using Moq; + +namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; + +public class OAuthConsentEmissionTests +{ + private static ResponseEventStream CreateTestStream() + { + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + var request = new CreateResponse { Model = "test-model" }; + return new ResponseEventStream(mockContext.Object, request); + } + + [Fact] + public void EmitOAuthConsentRequest_EmitsOAuthConsentRequestItem_NotMcpApproval() + { + // Arrange + const string ConsentUrl = "https://login.microsoftonline.com/consent?data=abc"; + const string ServerLabel = "outlook_mail"; + var stream = CreateTestStream(); + + // Act: emit the consent request (added → done). + List events = + AgentFrameworkResponseHandler.EmitOAuthConsentRequest(stream, ServerLabel, ConsentUrl).ToList(); + + // Assert: exactly an output_item.added followed by output_item.done, carrying an + // oauth_consent_request item with the consent link and server label. A valid wire id is + // required by AddOutputItem; if the generated id were malformed this call would have thrown. + Assert.Equal(2, events.Count); + var added = Assert.IsType(events[0]); + var done = Assert.IsType(events[1]); + + var addedItem = Assert.IsType(added.Item); + Assert.Equal(ConsentUrl, addedItem.ConsentLink); + Assert.Equal(ServerLabel, addedItem.ServerLabel); + Assert.StartsWith("oacr_", addedItem.Id); + + var doneItem = Assert.IsType(done.Item); + Assert.Equal(addedItem.Id, doneItem.Id); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ToolboxConsentParserTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ToolboxConsentParserTests.cs new file mode 100644 index 00000000000..4f3e961526c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ToolboxConsentParserTests.cs @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; + +public class ToolboxConsentParserTests +{ + [Fact] + public void TryParseConsentRequired_SingleConsentError_ReturnsTrueWithUrlAndToolName() + { + // Arrange: the exact aggregate tools/list failure shape the proxy returns when a + // single tool source needs OAuth consent before it can be enumerated. + const string Message = + "Request failed (remote): tools/list failed for 1 tool source(s), succeeded for 0 tool source(s) " + + "{\"errors\":[{\"name\":\"send_email\",\"type\":\"mcp\",\"error\":{\"code\":\"CONSENT_REQUIRED\",\"message\":\"https://login.example.com/consent?data=abc\"}}]}"; + + // Act + var parsed = ToolboxConsentParser.TryParseConsentRequired("auth-paths-toolbox", Message, out var consents); + + // Assert + Assert.True(parsed); + var consent = Assert.Single(consents); + Assert.Equal("auth-paths-toolbox", consent.ToolboxName); + Assert.Equal("send_email", consent.ToolName); + Assert.Equal("https://login.example.com/consent?data=abc", consent.ConsentUrl); + } + + [Fact] + public void TryParseConsentRequired_MultipleConsentErrors_ReturnsAll() + { + // Arrange + const string Message = + "tools/list failed " + + "{\"errors\":[" + + "{\"name\":\"send_email\",\"type\":\"mcp\",\"error\":{\"code\":\"CONSENT_REQUIRED\",\"message\":\"https://consent/a\"}}," + + "{\"name\":\"read_calendar\",\"type\":\"mcp\",\"error\":{\"code\":\"CONSENT_REQUIRED\",\"message\":\"https://consent/b\"}}]}"; + + // Act + var parsed = ToolboxConsentParser.TryParseConsentRequired("toolbox", Message, out var consents); + + // Assert + Assert.True(parsed); + Assert.Equal(2, consents.Count); + Assert.Equal("send_email", consents[0].ToolName); + Assert.Equal("https://consent/a", consents[0].ConsentUrl); + Assert.Equal("read_calendar", consents[1].ToolName); + Assert.Equal("https://consent/b", consents[1].ConsentUrl); + } + + [Fact] + public void TryParseConsentRequired_MixedWithNonConsentError_ReturnsFalse() + { + // Arrange: when any source fails for a non-consent reason, consent alone cannot make + // enumeration succeed, so the caller must treat the failure as a hard error. + const string Message = + "tools/list failed " + + "{\"errors\":[" + + "{\"name\":\"send_email\",\"type\":\"mcp\",\"error\":{\"code\":\"CONSENT_REQUIRED\",\"message\":\"https://consent/a\"}}," + + "{\"name\":\"broken\",\"type\":\"mcp\",\"error\":{\"code\":\"INTERNAL_ERROR\",\"message\":\"boom\"}}]}"; + + // Act + var parsed = ToolboxConsentParser.TryParseConsentRequired("toolbox", Message, out var consents); + + // Assert + Assert.False(parsed); + Assert.Empty(consents); + } + + [Fact] + public void TryParseConsentRequired_NoJsonPayload_ReturnsFalse() + { + // Act + var parsed = ToolboxConsentParser.TryParseConsentRequired("toolbox", "connection refused", out var consents); + + // Assert + Assert.False(parsed); + Assert.Empty(consents); + } + + [Fact] + public void TryParseConsentRequired_CodePresentButMalformedJson_ReturnsFalse() + { + // Arrange: the marker code is present but the embedded JSON is not parseable. + const string Message = "tools/list failed CONSENT_REQUIRED {\"errors\":[ not valid json"; + + // Act + var parsed = ToolboxConsentParser.TryParseConsentRequired("toolbox", Message, out var consents); + + // Assert + Assert.False(parsed); + Assert.Empty(consents); + } + + [Fact] + public void TryParseConsentRequired_ConsentErrorWithoutUrl_ReturnsFalse() + { + // Arrange: a CONSENT_REQUIRED error with no message means there is no URL to present. + const string Message = + "tools/list failed " + + "{\"errors\":[{\"name\":\"send_email\",\"type\":\"mcp\",\"error\":{\"code\":\"CONSENT_REQUIRED\",\"message\":\"\"}}]}"; + + // Act + var parsed = ToolboxConsentParser.TryParseConsentRequired("toolbox", Message, out var consents); + + // Assert + Assert.False(parsed); + Assert.Empty(consents); + } + + [Fact] + public void TryParseConsentRequired_MissingToolName_FallsBackToToolboxName() + { + // Arrange: when the failing source has no name, the toolbox name is used as the tool name. + const string Message = + "tools/list failed " + + "{\"errors\":[{\"type\":\"mcp\",\"error\":{\"code\":\"CONSENT_REQUIRED\",\"message\":\"https://consent/x\"}}]}"; + + // Act + var parsed = ToolboxConsentParser.TryParseConsentRequired("my-toolbox", Message, out var consents); + + // Assert + Assert.True(parsed); + var consent = Assert.Single(consents); + Assert.Equal("my-toolbox", consent.ToolName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void TryParseConsentRequired_NullOrEmptyMessage_ReturnsFalse(string? message) + { + // Act + var parsed = ToolboxConsentParser.TryParseConsentRequired("toolbox", message, out var consents); + + // Assert + Assert.False(parsed); + Assert.Empty(consents); + } +}