From 9baf76ea7715b1c26420a5dc8870eff945cf80f1 Mon Sep 17 00:00:00 2001
From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
Date: Thu, 16 Apr 2026 22:36:22 +0100
Subject: [PATCH] feat: Add Foundry Toolbox (MCP) support to
AgentFrameworkResponseHandler
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds support for Foundry Toolsets MCP proxy integration in the hosted agent
response handler. Toolsets connect at startup via IHostedService, gating the
readiness probe per spec §3.1. MCP tools are injected into every request's
ChatOptions and OAuth consent errors (-32006) are intercepted and surfaced as
mcp_approval_request + incomplete SSE events.
New files:
- FoundryToolboxOptions.cs: configuration POCO for toolset names and API version
- FoundryToolboxBearerTokenHandler.cs: DelegatingHandler with Azure Bearer token
auth, Foundry-Features header injection, and 3x exponential backoff on 429/5xx
- McpConsentContext.cs: AsyncLocal-based per-request consent state shared between
the tool wrapper and the response handler
- ConsentAwareMcpClientTool.cs: AIFunction wrapper that catches -32006 errors and
signals consent via shared state and linked CancellationTokenSource
- FoundryToolboxService.cs: IHostedService that creates McpClient per toolset at
startup and exposes cached tools
Modified files:
- AgentFrameworkResponseHandler.cs: injects toolbox tools into ChatOptions, sets
up linked CTS consent interception, emits mcp_approval_request on -32006
- ServiceCollectionExtensions.cs: adds AddFoundryToolboxes(params string[]) extension
- Microsoft.Agents.AI.Foundry.csproj: adds ModelContextProtocol and Azure.Identity
dependencies under NETCoreApp condition
Sample:
- Hosted-Toolbox: minimal hosted agent sample using AddFoundryToolboxes
---
dotnet/agent-framework-dotnet.slnx | 3 +
.../Hosted-Toolbox/HostedToolbox.csproj | 30 +++
.../responses/Hosted-Toolbox/Program.cs | 113 +++++++++++
.../Hosting/AgentFrameworkResponseHandler.cs | 50 ++++-
.../Hosting/ConsentAwareMcpClientTool.cs | 70 +++++++
.../FoundryToolboxBearerTokenHandler.cs | 107 ++++++++++
.../Hosting/FoundryToolboxOptions.cs | 30 +++
.../Hosting/FoundryToolboxService.cs | 189 ++++++++++++++++++
.../Hosting/McpConsentContext.cs | 45 +++++
.../Hosting/ServiceCollectionExtensions.cs | 55 +++++
.../Microsoft.Agents.AI.Foundry.csproj | 2 +
11 files changed, 691 insertions(+), 3 deletions(-)
create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj
create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/Program.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ConsentAwareMcpClientTool.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxBearerTokenHandler.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxOptions.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxService.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/McpConsentContext.cs
diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index 5cc3327b8d..9d9ee29b22 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -283,6 +283,9 @@
+
+
+
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj
new file mode 100644
index 0000000000..1e3c4e10c2
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ HostedToolbox
+ HostedToolbox
+ $(NoWarn);
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/Program.cs
new file mode 100644
index 0000000000..959cc2d4f5
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/Program.cs
@@ -0,0 +1,113 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// Foundry Toolbox Agent - A hosted agent that uses Foundry Toolset MCP tools.
+//
+// Demonstrates how to register one or more Foundry toolsets so the agent can
+// call tools provided by the Foundry platform's managed MCP proxy.
+//
+// Required environment variables:
+// AZURE_AI_PROJECT_ENDPOINT - Azure AI Foundry project endpoint
+// AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-4o)
+// FOUNDRY_AGENT_TOOLSET_ENDPOINT - Foundry Toolsets proxy base URL
+// (injected automatically by Foundry platform at runtime)
+//
+// Optional:
+// FOUNDRY_TOOLSET_NAME - Name of the toolset to load (default: my-toolset)
+// FOUNDRY_AGENT_NAME - Client name reported to MCP server
+// FOUNDRY_AGENT_VERSION - Client version reported to MCP server
+// FOUNDRY_AGENT_TOOLSET_FEATURES - Feature flags sent to Foundry proxy via header
+
+using Azure.AI.Projects;
+using Azure.Core;
+using Azure.Identity;
+using DotNetEnv;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Foundry.Hosting;
+
+// Load .env file if present (for local development)
+Env.TraversePath().Load();
+
+string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
+ ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
+string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
+string toolsetName = Environment.GetEnvironmentVariable("FOUNDRY_TOOLSET_NAME") ?? "my-toolset";
+
+// Use a chained credential: try a temporary dev token first (for local Docker debugging),
+// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production).
+TokenCredential credential = new ChainedTokenCredential(
+ new DevTemporaryTokenCredential(),
+ new DefaultAzureCredential());
+
+// ── Create agent ─────────────────────────────────────────────────────────────
+
+AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
+ .AsAIAgent(
+ model: deploymentName,
+ instructions: """
+ You are a helpful assistant with access to tools provided by the Foundry Toolset.
+ Use the available tools to answer user questions.
+ If a tool is not available for a request, let the user know clearly.
+ """,
+ name: Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-toolbox-agent",
+ description: "Hosted agent backed by Foundry Toolset MCP tools");
+
+// ── Build the host ────────────────────────────────────────────────────────────
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Register the agent and response handler
+builder.Services.AddFoundryResponses(agent);
+
+// Register Foundry Toolbox: connects to the MCP proxy at startup and makes tools available.
+// The toolset name must match a toolset registered in your Foundry project.
+// When FOUNDRY_AGENT_TOOLSET_ENDPOINT is absent (e.g., in local development without Foundry
+// infrastructure), startup succeeds without error and no toolbox tools are loaded.
+builder.Services.AddFoundryToolboxes(toolsetName);
+
+var app = builder.Build();
+app.MapFoundryResponses();
+
+if (app.Environment.IsDevelopment())
+{
+ app.MapFoundryResponses("openai/v1");
+}
+
+app.Run();
+
+// ── DevTemporaryTokenCredential ───────────────────────────────────────────────
+
+///
+/// A for local Docker debugging only.
+/// Reads a pre-fetched bearer token from the AZURE_BEARER_TOKEN environment variable
+/// once at startup. This should NOT be used in production.
+///
+/// Generate a token on your host and pass it to the container:
+/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
+/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
+///
+internal sealed class DevTemporaryTokenCredential : TokenCredential
+{
+ private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
+ private readonly string? _token;
+
+ public DevTemporaryTokenCredential()
+ {
+ this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
+ }
+
+ public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
+ => this.GetAccessToken();
+
+ public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
+ => new(this.GetAccessToken());
+
+ private AccessToken GetAccessToken()
+ {
+ if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
+ {
+ throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
+ }
+
+ return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1));
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs
index ab0d4f50aa..be7566d5bb 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs
@@ -21,6 +21,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger _logger;
+ private readonly FoundryToolboxService? _toolboxService;
///
/// Initializes a new instance of the class
@@ -28,15 +29,18 @@ public class AgentFrameworkResponseHandler : ResponseHandler
///
/// The service provider for resolving agents.
/// The logger instance.
+ /// Optional Foundry Toolbox service providing MCP tools.
public AgentFrameworkResponseHandler(
IServiceProvider serviceProvider,
- ILogger logger)
+ ILogger logger,
+ FoundryToolboxService? toolboxService = null)
{
ArgumentNullException.ThrowIfNull(serviceProvider);
ArgumentNullException.ThrowIfNull(logger);
this._serviceProvider = serviceProvider;
this._logger = logger;
+ this._toolboxService = toolboxService;
}
///
@@ -92,14 +96,33 @@ public class AgentFrameworkResponseHandler : ResponseHandler
// 5. Build chat options
var chatOptions = InputConverter.ConvertToChatOptions(request);
chatOptions.Instructions = request.Instructions;
+
+ // Inject Foundry Toolbox tools when the toolbox service is available
+ if (this._toolboxService is not null)
+ {
+ var toolboxTools = this._toolboxService.Tools;
+ if (toolboxTools.Count > 0)
+ {
+ chatOptions.Tools = [.. chatOptions.Tools ?? [], .. toolboxTools];
+ }
+ }
+
var options = new ChatClientAgentRunOptions(chatOptions);
- // 6. Run the agent and convert output
+ // 6. Set up consent context for -32006 OAuth consent interception.
+ // We create a linked CTS so the consent-aware tool wrapper can cancel the agent
+ // run mid-loop when a -32006 error is returned by the proxy. The RequestConsentState
+ // is a shared mutable object that flows via AsyncLocal to the tool wrapper.
+ using var consentCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ var consentState = new RequestConsentState { CancellationSource = consentCts };
+ McpConsentContext.Current.Value = consentState;
+
+ // 7. Run the agent and convert output
// NOTE: C# forbids 'yield return' inside a try block that has a catch clause,
// and inside catch blocks. We use a flag to defer the yield to outside the try/catch.
bool emittedTerminal = false;
var enumerator = OutputConverter.ConvertUpdatesToEventsAsync(
- agent.RunStreamingAsync(messages, session, options: options, cancellationToken: cancellationToken),
+ agent.RunStreamingAsync(messages, session, options: options, cancellationToken: consentCts.Token),
stream,
cancellationToken).GetAsyncEnumerator(cancellationToken);
try
@@ -107,6 +130,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler
while (true)
{
bool shutdownDetected = false;
+ McpConsentInfo? consentInfo = null;
ResponseStreamEvent? failedEvent = null;
ResponseStreamEvent? evt = null;
try
@@ -118,6 +142,11 @@ public class AgentFrameworkResponseHandler : ResponseHandler
evt = enumerator.Current;
}
+ catch (OperationCanceledException) when (!emittedTerminal && consentState.Pending is not null)
+ {
+ // -32006 consent error: the tool wrapper cancelled consentCts and stored consent info.
+ consentInfo = consentState.Pending;
+ }
catch (OperationCanceledException) when (context.IsShutdownRequested && !emittedTerminal)
{
shutdownDetected = true;
@@ -137,6 +166,21 @@ public class AgentFrameworkResponseHandler : ResponseHandler
ex.Message);
}
+ if (consentInfo is not null)
+ {
+ // Emit mcp_approval_request output item + incomplete for the consent URL.
+ foreach (var approvalEvent in stream.OutputItemMcpApprovalRequest(
+ consentInfo.ToolsetName,
+ consentInfo.ToolName,
+ consentInfo.ConsentUrl))
+ {
+ yield return approvalEvent;
+ }
+
+ yield return stream.EmitIncomplete(reason: null);
+ yield break;
+ }
+
if (failedEvent is not null)
{
yield return failedEvent;
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ConsentAwareMcpClientTool.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ConsentAwareMcpClientTool.cs
new file mode 100644
index 0000000000..467660756f
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ConsentAwareMcpClientTool.cs
@@ -0,0 +1,70 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using ModelContextProtocol;
+using ModelContextProtocol.Client;
+
+namespace Microsoft.Agents.AI.Foundry.Hosting;
+
+///
+/// An wrapper around that intercepts
+/// JSON-RPC error -32006 (OAuth consent required) from the Foundry Toolsets proxy and
+/// propagates it back to via
+/// .
+///
+///
+///
+/// When the proxy returns -32006, the consent URL is stored in
+/// and the per-request is cancelled. This causes
+/// to stop the tool loop (it guards
+/// exceptions with when (!ct.IsCancellationRequested)) and surfaces an
+/// to the handler. The handler then emits the
+/// mcp_approval_request output item and marks the response as incomplete.
+///
+///
+internal sealed class ConsentAwareMcpClientTool : AIFunction
+{
+ private readonly McpClientTool _inner;
+ private readonly string _toolsetName;
+
+ internal ConsentAwareMcpClientTool(McpClientTool inner, string toolsetName)
+ {
+ this._inner = inner;
+ this._toolsetName = toolsetName;
+ }
+
+ public override string Name => this._inner.Name;
+
+ public override string Description => this._inner.Description;
+
+ public override JsonElement JsonSchema => this._inner.JsonSchema;
+
+ public override JsonElement? ReturnJsonSchema => this._inner.ReturnJsonSchema;
+
+ public override JsonSerializerOptions JsonSerializerOptions => this._inner.JsonSerializerOptions;
+
+ protected override async ValueTask