From bbf37bbddef0d951aaa84d5ad82ce8f69fd6fa08 Mon Sep 17 00:00:00 2001
From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com>
Date: Wed, 24 Jun 2026 20:24:03 +0100
Subject: [PATCH 1/5] .NET: Foundry hosted-agent toolbox OAuth consent support
Add per-user OAuth (MCP CONSENT_REQUIRED) support for Foundry hosted agents.
* Defer hard toolbox startup failures so a per-user OAuth-gated toolbox no
longer bricks the container at startup (new Degraded status, retried per
request). The container stays routable and surfaces consent on the first
user request.
* Emit the platform-canonical oauth_consent_request output item (instead of
mcp_approval_request) for toolbox OAuth consent, matching the Python
implementation and how the Foundry platform heads render consent.
* Parse the toolbox CONSENT_REQUIRED (-32006) error and surface the consent
link; resume by re-sending the prompt with no reply item needed.
* Add the Hosted-Toolbox-AuthPaths OAuth consent REPL client sample that
detects oauth_consent_request, prints the consent link, and re-sends.
* Add tests for the consent parser, startup deferral, and oauth_consent_request
emission.
Fixes #6562
---
dotnet/agent-framework-dotnet.slnx | 1 +
.../Hosted-Toolbox-AuthPaths/README.md | 12 +-
.../Hosted-Toolbox-AuthPaths-Client.csproj | 26 ++
.../Program.cs | 321 ++++++++++++++++++
.../Hosted-Toolbox-AuthPaths-Client/README.md | 76 +++++
.../AgentFrameworkResponseHandler.cs | 81 ++++-
.../FoundryToolboxHealthCheck.cs | 14 +
.../FoundryToolboxService.cs | 296 +++++++++++++++-
.../FoundryToolboxStartupStatus.cs | 19 ++
.../ToolboxConsentParser.cs | 126 +++++++
.../FoundryToolboxHealthCheckTests.cs | 20 +-
.../FoundryToolboxServiceTests.cs | 75 ++--
.../OAuthConsentEmissionTests.cs | 47 +++
.../ToolboxConsentParserTests.cs | 138 ++++++++
14 files changed, 1200 insertions(+), 52 deletions(-)
create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/Hosted-Toolbox-AuthPaths-Client/Hosted-Toolbox-AuthPaths-Client.csproj
create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/Hosted-Toolbox-AuthPaths-Client/Program.cs
create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/Hosted-Toolbox-AuthPaths-Client/README.md
create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ToolboxConsentParser.cs
create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OAuthConsentEmissionTests.cs
create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ToolboxConsentParserTests.cs
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..cafb3cdcf66
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/Hosted-Toolbox-AuthPaths-Client/Program.cs
@@ -0,0 +1,321 @@
+// 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 System.Text.RegularExpressions;
+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,
+ };
+
+ if (arguments is null)
+ {
+ return null;
+ }
+
+ foreach (object? value in arguments.Values)
+ {
+ if (value is string s && LooksLikeUrl(s))
+ {
+ return s;
+ }
+
+ if (value is JsonElement { ValueKind: JsonValueKind.String } element
+ && element.GetString() is string elementString
+ && LooksLikeUrl(elementString))
+ {
+ return elementString;
+ }
+ }
+
+ string serialized = JsonSerializer.Serialize(arguments);
+ Match match = new Regex("https?://[^\\s\"']+").Match(serialized);
+ return match.Success ? match.Value : 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..d078d0b0ac9 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)
@@ -286,13 +320,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 +368,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.added → output_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..eaffcb3b03c 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs
@@ -13,6 +13,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 +50,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 +83,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 .
///
@@ -130,7 +152,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 +164,237 @@ 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.ConsentRequiredToolboxNames = [.. this._pendingConsents.Keys];
+ this.RecomputeStatus();
+ }
+
+ ///
+ /// Recomputes and refreshes from
+ /// the current failed, pending-consent and deferred sets. 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.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.ConsentRequiredToolboxNames = [.. this._pendingConsents.Keys];
+ 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.ConsentRequiredToolboxNames = [.. this._pendingConsents.Keys];
+ this.RecomputeStatus();
+ }
+ finally
+ {
+ this._lazyOpenLock.Release();
+ }
}
///
@@ -217,7 +447,18 @@ public async ValueTask> GetToolboxToolsAsync(
return cached.Tools;
}
- cached = await this.OpenToolboxAsync(toolboxName, version, cancellationToken).ConfigureAwait(false);
+ 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.
+ // Record it as pending so the request handler can surface the consent prompt,
+ // and return no tools for this turn rather than failing the request.
+ this._pendingConsents[toolboxName] = pendingConsents;
+ this.RecomputeStatus();
+ return [];
+ }
+
+ cached = result.Cached!;
this._toolboxes[toolboxName] = cached;
return cached.Tools;
}
@@ -227,7 +468,7 @@ public async ValueTask> GetToolboxToolsAsync(
}
}
- private async Task OpenToolboxAsync(
+ private async Task OpenToolboxAsync(
string toolboxName,
string? version,
CancellationToken cancellationToken)
@@ -268,7 +509,27 @@ private async Task OpenToolboxAsync(
clientOptions,
cancellationToken: cancellationToken).ConfigureAwait(false);
- var mcpTools = await client.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
+ IList mcpTools;
+ try
+ {
+ 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.
+ await client.DisposeAsync().ConfigureAwait(false);
+ httpClient.Dispose();
+ return new ToolboxOpenResult(Cached: null, Consents: consents);
+ }
+ catch
+ {
+ await client.DisposeAsync().ConfigureAwait(false);
+ httpClient.Dispose();
+ throw;
+ }
if (this._logger.IsEnabled(LogLevel.Information))
{
@@ -286,7 +547,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);
}
///
@@ -306,4 +567,11 @@ public async ValueTask DisposeAsync()
}
private sealed record CachedToolbox(McpClient Client, HttpClient HttpClient, IReadOnlyList Tools);
+
+ ///
+ /// 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.
+ ///
+ private 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..ce81f1d19a8
--- /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 mcp_approval_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/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/FoundryToolboxServiceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxServiceTests.cs
index d8a56adcd6f..91d33f3a397 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,40 @@ 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);
+ }
+
+ [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);
+ }
+}
From beb5d4af6971d3ea0f47cd0e54411487cd8aeee0 Mon Sep 17 00:00:00 2001
From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com>
Date: Wed, 24 Jun 2026 20:54:52 +0100
Subject: [PATCH 2/5] .NET: Address review feedback on toolbox OAuth consent
* Make RecomputeStatus the single source that refreshes ConsentRequiredToolboxNames
from the pending-consent set, so a per-request marker that records consent via
GetToolboxToolsAsync no longer leaves ConsentRequiredToolboxNames stale (which
made ResolvePendingConsentsAsync skip surfacing it).
* Surface lazy / per-request marker consent in the same request: after resolving
markers the handler now emits oauth_consent_request + incomplete when a marker
hit CONSENT_REQUIRED, instead of silently running without that toolbox.
* Add FoundryToolboxService.GetPendingConsents() snapshot accessor.
* Fix stale ToolboxConsentParser doc comment (mcp_approval_request -> oauth_consent_request).
---
.../AgentFrameworkResponseHandler.cs | 23 ++++++++++++
.../FoundryToolboxService.cs | 35 +++++++++++++++----
.../ToolboxConsentParser.cs | 2 +-
.../FoundryToolboxServiceTests.cs | 6 ++++
4 files changed, 59 insertions(+), 7 deletions(-)
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs
index d078d0b0ac9..c7ab84d1638 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs
@@ -252,6 +252,29 @@ await this._toolboxService
yield break;
}
+ // A lazy / per-request marker may have hit CONSENT_REQUIRED while resolving above
+ // (GetToolboxToolsAsync records the pending consent and returns no tools). Surface it
+ // now as an oauth_consent_request and stop, instead of silently running this turn
+ // without that toolbox. Any consent pending before marker resolution was already
+ // drained by ResolvePendingConsentsAsync, so anything here is newly discovered.
+ var markerConsents = this._toolboxService.GetPendingConsents();
+ if (markerConsents.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];
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs
index eaffcb3b03c..31d1caa9713 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs
@@ -217,19 +217,22 @@ public async Task StartAsync(CancellationToken cancellationToken)
}
this.FailedToolboxNames = [];
- this.ConsentRequiredToolboxNames = [.. this._pendingConsents.Keys];
this.RecomputeStatus();
}
///
- /// Recomputes and refreshes from
- /// the current failed, pending-consent and deferred sets. A hard failure dominates; otherwise an
- /// outstanding consent requirement keeps the container routable via
+ /// 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
@@ -241,6 +244,28 @@ private void RecomputeStatus()
: FoundryToolboxStartupStatus.Healthy;
}
+ ///
+ /// Returns a snapshot of the consent requirements currently outstanding across all toolboxes
+ /// (pre-registered and per-request markers), flattened to one entry per consent-gated tool source.
+ /// Does not retry or open anything; the caller surfaces each entry as an
+ /// oauth_consent_request. Empty when nothing is awaiting consent.
+ ///
+ internal IReadOnlyList GetPendingConsents()
+ {
+ if (this._pendingConsents.Count == 0)
+ {
+ return [];
+ }
+
+ var snapshot = new List();
+ foreach (var consents in this._pendingConsents.Values)
+ {
+ snapshot.AddRange(consents);
+ }
+
+ return snapshot;
+ }
+
///
/// 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
@@ -310,7 +335,6 @@ internal async ValueTask> ResolvePendingConsentsAs
this.Tools = [.. this.Tools, .. resolvedTools];
}
- this.ConsentRequiredToolboxNames = [.. this._pendingConsents.Keys];
this.RecomputeStatus();
return stillPending;
@@ -388,7 +412,6 @@ internal async ValueTask RetryDeferredToolboxesAsync(CancellationToken cancellat
this.Tools = [.. this.Tools, .. resolvedTools];
}
- this.ConsentRequiredToolboxNames = [.. this._pendingConsents.Keys];
this.RecomputeStatus();
}
finally
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ToolboxConsentParser.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ToolboxConsentParser.cs
index ce81f1d19a8..806a613b2be 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ToolboxConsentParser.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ToolboxConsentParser.cs
@@ -24,7 +24,7 @@ namespace Microsoft.Agents.AI.Foundry.Hosting;
///
/// 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 mcp_approval_request instead of failing readiness.
+/// requirement as a per-request oauth_consent_request instead of failing readiness.
///
///
internal static class ToolboxConsentParser
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 91d33f3a397..f3a763d4af8 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxServiceTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxServiceTests.cs
@@ -196,6 +196,12 @@ public async Task StartAsync_WithEndpointButFailingToolbox_RecordsFailureAndStay
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
+ // and GetPendingConsents() 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);
+ Assert.Empty(service.GetPendingConsents());
}
[Fact]
From 888f9fef7b639b1c60d6720711a8eb8ba7062db8 Mon Sep 17 00:00:00 2001
From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com>
Date: Wed, 24 Jun 2026 21:32:57 +0100
Subject: [PATCH 3/5] .NET: Harden toolbox consent paths from code review
* Thread-safety: GetPendingConsents() now returns an immutable snapshot rebuilt
in RecomputeStatus under the lock, instead of enumerating the live
_pendingConsents dictionary off-lock (which could throw under concurrent requests).
* Resource leak: OpenToolboxAsync builds the endpoint Uri before allocating the
HttpClient and now disposes the HttpClient when McpClient.CreateAsync throws
(the unreachable/deferred case retried per request), not only when ListToolsAsync fails.
* StrictMode now gates on the pre-registered ToolboxNames set rather than the
opened-toolbox cache, so a registered-but-deferred toolbox is no longer rejected
as unknown.
* Sample REPL: the legacy approval-args consent fallback only reads the explicit
consent_url key, so a normal function-tool approval carrying a URL argument is
not misread as an OAuth consent request.
---
.../Program.cs | 29 +++----
.../FoundryToolboxService.cs | 75 ++++++++++++-------
2 files changed, 60 insertions(+), 44 deletions(-)
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
index cafb3cdcf66..51ac9c3dd5d 100644
--- 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
@@ -32,7 +32,6 @@
using System.ClientModel.Primitives;
using System.Text.Json;
-using System.Text.RegularExpressions;
using Azure.AI.Projects;
using Azure.Identity;
using DotNetEnv;
@@ -262,29 +261,21 @@ Type a message or 'quit' to exit.
_ => null,
};
- if (arguments is 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;
}
- foreach (object? value in arguments.Values)
+ return value switch
{
- if (value is string s && LooksLikeUrl(s))
- {
- return s;
- }
-
- if (value is JsonElement { ValueKind: JsonValueKind.String } element
- && element.GetString() is string elementString
- && LooksLikeUrl(elementString))
- {
- return elementString;
- }
- }
-
- string serialized = JsonSerializer.Serialize(arguments);
- Match match = new Regex("https?://[^\\s\"']+").Match(serialized);
- return match.Success ? match.Value : null;
+ 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) =>
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs
index 31d1caa9713..778bfaf776d 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;
@@ -54,6 +55,10 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
private readonly HashSet _deferredToolboxNames = new(StringComparer.OrdinalIgnoreCase);
private readonly SemaphoreSlim _lazyOpenLock = new(1, 1);
+ // Immutable snapshot of _pendingConsents, rebuilt in RecomputeStatus under _lazyOpenLock so
+ // GetPendingConsents can return it without enumerating the live dictionary off-lock.
+ private IReadOnlyList _pendingConsentsSnapshot = [];
+
private string? _resolvedEndpoint;
private string? _featuresHeader;
private string _agentName = "hosted-agent";
@@ -235,6 +240,25 @@ private void RecomputeStatus()
this.ConsentRequiredToolboxNames = [.. this._pendingConsents.Keys];
this.DeferredToolboxNames = [.. this._deferredToolboxNames];
+ // Flatten the pending-consent map to an immutable snapshot here, while we hold the lock
+ // (RecomputeStatus is only ever called under _lazyOpenLock). GetPendingConsents then returns
+ // this snapshot without touching the live dictionary, so concurrent requests cannot observe a
+ // mutating collection.
+ if (this._pendingConsents.Count == 0)
+ {
+ this._pendingConsentsSnapshot = [];
+ }
+ else
+ {
+ var snapshot = new List();
+ foreach (var consents in this._pendingConsents.Values)
+ {
+ snapshot.AddRange(consents);
+ }
+
+ this._pendingConsentsSnapshot = snapshot;
+ }
+
this.StartupStatus = this.FailedToolboxNames.Count > 0
? FoundryToolboxStartupStatus.Unhealthy
: this._pendingConsents.Count > 0
@@ -250,21 +274,7 @@ private void RecomputeStatus()
/// Does not retry or open anything; the caller surfaces each entry as an
/// oauth_consent_request. Empty when nothing is awaiting consent.
///
- internal IReadOnlyList GetPendingConsents()
- {
- if (this._pendingConsents.Count == 0)
- {
- return [];
- }
-
- var snapshot = new List();
- foreach (var consents in this._pendingConsents.Values)
- {
- snapshot.AddRange(consents);
- }
-
- return snapshot;
- }
+ internal IReadOnlyList GetPendingConsents() => this._pendingConsentsSnapshot;
///
/// Retries enumeration for any pre-registered toolbox that was awaiting user OAuth consent at
@@ -448,7 +458,7 @@ public async ValueTask> GetToolboxToolsAsync(
return 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(...). " +
@@ -503,6 +513,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()
@@ -512,7 +525,7 @@ private async Task OpenToolboxAsync(
var transportOptions = new HttpClientTransportOptions
{
- Endpoint = new Uri(proxyUrl),
+ Endpoint = endpoint,
Name = toolboxName,
};
@@ -527,14 +540,18 @@ 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 (
@@ -543,13 +560,21 @@ private async Task OpenToolboxAsync(
// 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.
- await client.DisposeAsync().ConfigureAwait(false);
+ if (client is not null)
+ {
+ await client.DisposeAsync().ConfigureAwait(false);
+ }
+
httpClient.Dispose();
return new ToolboxOpenResult(Cached: null, Consents: consents);
}
catch
{
- await client.DisposeAsync().ConfigureAwait(false);
+ if (client is not null)
+ {
+ await client.DisposeAsync().ConfigureAwait(false);
+ }
+
httpClient.Dispose();
throw;
}
@@ -570,7 +595,7 @@ private async Task OpenToolboxAsync(
_ = version; // reserved for future version-specific routing; currently handled server-side by the proxy.
- return new ToolboxOpenResult(new CachedToolbox(client, httpClient, wrapped), Consents: null);
+ return new ToolboxOpenResult(new CachedToolbox(client!, httpClient, wrapped), Consents: null);
}
///
From 046063b78f85f1b7022e2d616b2382ecaa9725bc Mon Sep 17 00:00:00 2001
From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com>
Date: Wed, 24 Jun 2026 22:47:34 +0100
Subject: [PATCH 4/5] .NET: Scope per-request toolbox marker consent to the
request
Addresses review feedback that a marker-originated toolbox could leak into global
scope after consent. GetToolboxToolsAsync now returns a request-scoped
ToolboxResolution (tools or consent requirements) instead of recording marker
consent in the container-global _pendingConsents and appending resolved tools to
the service-wide Tools list.
* Marker consent is surfaced as oauth_consent_request for the requesting turn only
and collected in the handler's marker loop; it no longer injects tools into, or
raises a consent prompt on, a later request that did not reference the marker.
* Marker resolution no longer flips the container StartupStatus to ConsentRequired
(per-request markers must not affect readiness, per the StartupStatus contract).
* Remove the now-unused GetPendingConsents()/snapshot path; _pendingConsents is once
again exclusively the pre-registered/startup consent set.
---
.../AgentFrameworkResponseHandler.cs | 28 +++++---
.../FoundryToolboxService.cs | 68 ++++++++-----------
.../FoundryToolboxServiceTests.cs | 5 +-
3 files changed, 47 insertions(+), 54 deletions(-)
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs
index c7ab84d1638..f461779c5dd 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs
@@ -206,6 +206,7 @@ await this._toolboxService
var markers = InputConverter.ReadMcpToolboxMarkers(request);
var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
string? resolutionError = null;
+ List? markerConsents = null;
foreach (var (name, version) in markers)
{
@@ -214,10 +215,10 @@ await this._toolboxService
continue;
}
- IReadOnlyList? toolboxTools = null;
+ FoundryToolboxService.ToolboxResolution resolution;
try
{
- toolboxTools = await this._toolboxService
+ resolution = await this._toolboxService
.GetToolboxToolsAsync(name, version, cancellationToken)
.ConfigureAwait(false);
}
@@ -236,8 +237,17 @@ await this._toolboxService
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))
{
@@ -252,13 +262,11 @@ await this._toolboxService
yield break;
}
- // A lazy / per-request marker may have hit CONSENT_REQUIRED while resolving above
- // (GetToolboxToolsAsync records the pending consent and returns no tools). Surface it
- // now as an oauth_consent_request and stop, instead of silently running this turn
- // without that toolbox. Any consent pending before marker resolution was already
- // drained by ResolvePendingConsentsAsync, so anything here is newly discovered.
- var markerConsents = this._toolboxService.GetPendingConsents();
- if (markerConsents.Count > 0)
+ // 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)
{
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs
index 778bfaf776d..e70b0919db8 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs
@@ -55,10 +55,6 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
private readonly HashSet _deferredToolboxNames = new(StringComparer.OrdinalIgnoreCase);
private readonly SemaphoreSlim _lazyOpenLock = new(1, 1);
- // Immutable snapshot of _pendingConsents, rebuilt in RecomputeStatus under _lazyOpenLock so
- // GetPendingConsents can return it without enumerating the live dictionary off-lock.
- private IReadOnlyList _pendingConsentsSnapshot = [];
-
private string? _resolvedEndpoint;
private string? _featuresHeader;
private string _agentName = "hosted-agent";
@@ -240,25 +236,6 @@ private void RecomputeStatus()
this.ConsentRequiredToolboxNames = [.. this._pendingConsents.Keys];
this.DeferredToolboxNames = [.. this._deferredToolboxNames];
- // Flatten the pending-consent map to an immutable snapshot here, while we hold the lock
- // (RecomputeStatus is only ever called under _lazyOpenLock). GetPendingConsents then returns
- // this snapshot without touching the live dictionary, so concurrent requests cannot observe a
- // mutating collection.
- if (this._pendingConsents.Count == 0)
- {
- this._pendingConsentsSnapshot = [];
- }
- else
- {
- var snapshot = new List();
- foreach (var consents in this._pendingConsents.Values)
- {
- snapshot.AddRange(consents);
- }
-
- this._pendingConsentsSnapshot = snapshot;
- }
-
this.StartupStatus = this.FailedToolboxNames.Count > 0
? FoundryToolboxStartupStatus.Unhealthy
: this._pendingConsents.Count > 0
@@ -268,14 +245,6 @@ private void RecomputeStatus()
: FoundryToolboxStartupStatus.Healthy;
}
- ///
- /// Returns a snapshot of the consent requirements currently outstanding across all toolboxes
- /// (pre-registered and per-request markers), flattened to one entry per consent-gated tool source.
- /// Does not retry or open anything; the caller surfaces each entry as an
- /// oauth_consent_request. Empty when nothing is awaiting consent.
- ///
- internal IReadOnlyList GetPendingConsents() => this._pendingConsentsSnapshot;
-
///
/// 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
@@ -442,11 +411,19 @@ internal async ValueTask RetryDeferredToolboxesAsync(CancellationToken cancellat
/// 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)
@@ -455,7 +432,7 @@ public async ValueTask> GetToolboxToolsAsync(
if (this._toolboxes.TryGetValue(toolboxName, out var cached))
{
- return cached.Tools;
+ return new ToolboxResolution(cached.Tools, []);
}
if (this._options.StrictMode && !this._options.ToolboxNames.Contains(toolboxName, StringComparer.OrdinalIgnoreCase))
@@ -477,23 +454,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.
- // Record it as pending so the request handler can surface the consent prompt,
- // and return no tools for this turn rather than failing the request.
- this._pendingConsents[toolboxName] = pendingConsents;
- this.RecomputeStatus();
- return [];
+ // 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 = result.Cached!;
this._toolboxes[toolboxName] = cached;
- return cached.Tools;
+ return new ToolboxResolution(cached.Tools, []);
}
finally
{
@@ -616,6 +593,15 @@ public async ValueTask DisposeAsync()
private 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
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 f3a763d4af8..a6b11413575 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxServiceTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxServiceTests.cs
@@ -198,10 +198,9 @@ public async Task StartAsync_WithEndpointButFailingToolbox_RecordsFailureAndStay
Assert.Empty(service.Tools);
// A deferred (non-consent) toolbox is not a consent requirement: ConsentRequiredToolboxNames
- // and GetPendingConsents() must stay empty. RecomputeStatus is the single source that keeps
- // ConsentRequiredToolboxNames in sync with the pending-consent set, so they never diverge.
+ // 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);
- Assert.Empty(service.GetPendingConsents());
}
[Fact]
From e1d4071325928ef7b201df3459d18cda6a3bdfca Mon Sep 17 00:00:00 2001
From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com>
Date: Wed, 24 Jun 2026 23:15:26 +0100
Subject: [PATCH 5/5] .NET: Add consent request-scoping UTs and an OAuth
consent integration test
Unit tests (Microsoft.Agents.AI.Foundry.Hosting.UnitTests):
* New FoundryToolboxMarkerScopingTests proves per-request marker resolution is
request-scoped: a marker consent is returned to the caller without mutating
ConsentRequiredToolboxNames, StartupStatus, or the service-wide Tools cache, and
marker-resolved tools are returned to the caller rather than injected globally
(so a request with no marker sees neither the tools nor the consent).
* Adds a test-only ToolboxOpener seam on FoundryToolboxService so the consent/tools
resolution can be exercised without a live MCP proxy. Makes ToolboxOpenResult and
CachedToolbox internal (CachedToolbox.Client nullable, guarded at dispose).
Integration test (Foundry.Hosting.IntegrationTests):
* New toolbox-oauth-consent scenario wired into the TestContainer (pre-registers a
Foundry toolbox via AddFoundryToolboxes from IT_TOOLBOX_NAME), a
ToolboxOAuthConsentHostedAgentFixture, and a ToolboxOAuthConsentHostedAgentTests
that invokes the deployed agent and asserts the consumer captures an
oauth_consent_request consent link (container stays routable, no 424). Skipped by
default per the IT convention; documents the consent-gated toolbox prerequisite.
* Adds the scenario to it-bootstrap-agents.ps1 and the README scenario table.
---
.../FoundryToolboxService.cs | 25 +++-
.../Program.cs | 23 ++++
.../ToolboxOAuthConsentHostedAgentFixture.cs | 40 ++++++
.../README.md | 13 ++
.../ToolboxOAuthConsentHostedAgentTests.cs | 80 ++++++++++++
.../scripts/it-bootstrap-agents.ps1 | 1 +
.../FoundryToolboxMarkerScopingTests.cs | 123 ++++++++++++++++++
7 files changed, 302 insertions(+), 3 deletions(-)
create mode 100644 dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/ToolboxOAuthConsentHostedAgentFixture.cs
create mode 100644 dotnet/tests/Foundry.Hosting.IntegrationTests/ToolboxOAuthConsentHostedAgentTests.cs
create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxMarkerScopingTests.cs
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs
index e70b0919db8..bb6c50f9e63 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs
@@ -119,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)
{
@@ -483,6 +490,14 @@ private async Task OpenToolboxAsync(
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))
@@ -583,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();
}
@@ -591,7 +610,7 @@ 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
@@ -607,5 +626,5 @@ private sealed record CachedToolbox(McpClient Client, HttpClient HttpClient, IRe
/// when the toolbox opened and its tools were enumerated, or
/// when enumeration is blocked pending user OAuth consent.
///
- private sealed record ToolboxOpenResult(CachedToolbox? Cached, IReadOnlyList? Consents);
+ internal sealed record ToolboxOpenResult(CachedToolbox? Cached, IReadOnlyList? Consents);
}
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/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);
+ }
+}