diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index 1f7a9a6f3b..0dd890948a 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -22,7 +22,7 @@
-
+
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/nuget.config b/dotnet/nuget.config
index 76d943ce16..2316305f8e 100644
--- a/dotnet/nuget.config
+++ b/dotnet/nuget.config
@@ -3,10 +3,16 @@
+
+
+
+
+
+
\ No newline at end of file
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..4327562ee2
--- /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_TOOLBOX_NAME - Name of the toolset to load (default: my-toolset)
+// FOUNDRY_AGENT_NAME - Client name reported to MCP server
+// FOUNDRY_AGENT_VERSION - Client version reported to MCP server
+// FOUNDRY_AGENT_TOOLSET_FEATURES - Feature flags sent to Foundry proxy via header
+
+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 toolboxName = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_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(toolboxName);
+
+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.MaxValue);
+ }
+}
diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs
index d5f1c9a88d..e4661f3217 100644
--- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs
+++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs
@@ -8,6 +8,7 @@ using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Chat;
+using AgentCard = A2A.AgentCard;
namespace A2AServer;
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAITool.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAITool.cs
index 7721f8c013..d9ecb851e3 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAITool.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAITool.cs
@@ -112,6 +112,50 @@ public static class FoundryAITool
public static AITool CreateA2ATool(Uri baseUri, string? agentCardPath = null)
=> ProjectsAgentTool.CreateA2ATool(baseUri, agentCardPath).AsAITool();
+ ///
+ /// Creates an marker that references a Foundry Toolbox by name so
+ /// the hosted server side can resolve and expose its MCP tools for a single request.
+ ///
+ /// The Foundry toolbox name.
+ /// Optional pinned toolbox version. When , the project's default version is used.
+ /// An marker backed by .
+ public static AITool CreateHostedMcpToolbox(string toolboxName, string? version = null)
+ => new HostedMcpToolboxAITool(toolboxName, version);
+
+ ///
+ /// Creates an marker from a retrieved
+ /// from AIProjectClient. Uses and
+ /// .
+ ///
+ /// The toolbox record.
+ /// An marker backed by .
+ public static AITool CreateHostedMcpToolbox(ToolboxRecord toolbox)
+ {
+ if (toolbox is null)
+ {
+ throw new ArgumentNullException(nameof(toolbox));
+ }
+
+ return new HostedMcpToolboxAITool(toolbox.Name, toolbox.DefaultVersion);
+ }
+
+ ///
+ /// Creates an marker from a specific
+ /// retrieved from AIProjectClient. Uses and
+ /// .
+ ///
+ /// The toolbox version.
+ /// An marker backed by .
+ public static AITool CreateHostedMcpToolbox(ToolboxVersion toolboxVersion)
+ {
+ if (toolboxVersion is null)
+ {
+ throw new ArgumentNullException(nameof(toolboxVersion));
+ }
+
+ return new HostedMcpToolboxAITool(toolboxVersion.Name, toolboxVersion.Version);
+ }
+
// --- OpenAI SDK ResponseTool factories ---
///
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/HostedMcpToolboxAITool.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/HostedMcpToolboxAITool.cs
new file mode 100644
index 0000000000..6af3ab2ff0
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/HostedMcpToolboxAITool.cs
@@ -0,0 +1,156 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Extensions.AI;
+using Microsoft.Shared.DiagnosticIds;
+
+namespace Microsoft.Agents.AI.Foundry;
+
+///
+/// A marker that identifies a Foundry Toolbox by name
+/// (and optional version) on the OpenAI Responses mcp wire format.
+///
+///
+///
+/// The hosted server recognizes this marker by its
+/// scheme () and resolves it to the set of MCP tools exposed by the
+/// matching toolbox registered in the Foundry project.
+///
+///
+/// Callers should not construct this type directly. Use one of the
+/// FoundryAITool.CreateHostedMcpToolbox(...) factory overloads.
+///
+///
+[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
+public sealed class HostedMcpToolboxAITool : HostedMcpServerTool
+{
+ ///
+ /// The URI scheme used to identify Foundry Toolbox markers on the wire.
+ ///
+ public const string UriScheme = "foundry-toolbox";
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The Foundry toolbox name.
+ ///
+ /// Optional pinned toolbox version. When , the project's default version is used.
+ /// Currently reserved for forward compatibility — version-specific routing is handled server-side by
+ /// the Foundry proxy.
+ ///
+ public HostedMcpToolboxAITool(string toolboxName, string? version = null)
+ : base(
+ serverName: NotNullOrWhitespace(toolboxName, nameof(toolboxName)),
+ serverAddress: BuildAddress(toolboxName, version))
+ {
+ this.ToolboxName = toolboxName;
+ this.Version = version;
+ }
+
+ ///
+ /// Gets the Foundry toolbox name.
+ ///
+ public string ToolboxName { get; }
+
+ ///
+ /// Gets the pinned toolbox version, or to use the project's default.
+ ///
+ public string? Version { get; }
+
+ ///
+ /// Builds the toolbox marker address: foundry-toolbox://{name}[?version={v}].
+ ///
+ public static string BuildAddress(string toolboxName, string? version)
+ {
+ _ = NotNullOrWhitespace(toolboxName, nameof(toolboxName));
+
+ return string.IsNullOrEmpty(version)
+ ? $"{UriScheme}://{toolboxName}"
+ : $"{UriScheme}://{toolboxName}?version={Uri.EscapeDataString(version)}";
+ }
+
+ ///
+ /// Attempts to parse a toolbox marker address into its name and optional version components.
+ ///
+ /// The to inspect.
+ /// When this method returns , the parsed toolbox name.
+ /// When this method returns , the optional version, or .
+ /// if is a Foundry toolbox marker; otherwise .
+ public static bool TryParseToolboxAddress(
+ string? address,
+ [NotNullWhen(true)] out string? toolboxName,
+ out string? version)
+ {
+ toolboxName = null;
+ version = null;
+
+ if (string.IsNullOrEmpty(address))
+ {
+ return false;
+ }
+
+ if (!Uri.TryCreate(address, UriKind.Absolute, out var uri))
+ {
+ return false;
+ }
+
+ if (!string.Equals(uri.Scheme, UriScheme, StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ // For foundry-toolbox://name, the name appears as Authority (host) with an empty path.
+ // For foundry-toolbox:name (rare), it falls through to PathAndQuery.
+ var name = uri.Host;
+ if (string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(uri.AbsolutePath))
+ {
+ name = uri.AbsolutePath.TrimStart('/');
+ }
+
+ if (string.IsNullOrEmpty(name))
+ {
+ return false;
+ }
+
+ toolboxName = name;
+
+ var query = uri.Query;
+ if (!string.IsNullOrEmpty(query))
+ {
+ // Minimal parser to avoid a HttpUtility dependency on netstandard.
+ foreach (var part in query.TrimStart('?').Split('&'))
+ {
+ var eq = part.IndexOf('=');
+ if (eq <= 0)
+ {
+ continue;
+ }
+
+ var key = part.Substring(0, eq);
+ if (string.Equals(key, "version", StringComparison.OrdinalIgnoreCase))
+ {
+ version = Uri.UnescapeDataString(part.Substring(eq + 1));
+ break;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ private static string NotNullOrWhitespace(string value, string paramName)
+ {
+ if (value is null)
+ {
+ throw new ArgumentNullException(paramName);
+ }
+
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ throw new ArgumentException("Value cannot be empty or whitespace.", paramName);
+ }
+
+ return value;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs
index ab0d4f50aa..b9a0ebeb0f 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,97 @@ 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.
+ //
+ // Two sources are considered:
+ // 1. Pre-registered toolboxes (via AddFoundryToolboxes) — always appended.
+ // 2. Per-request markers embedded in request.Tools (HostedMcpToolboxAITool)
+ // whose ServerAddress scheme is "foundry-toolbox://". Strict mode rejects
+ // unknown names; otherwise a lazy MCP client is opened and cached.
+ //
+ // Each toolbox's tools are only appended once per request, even if it appears
+ // in both the pre-registered list and the per-request markers.
+ if (this._toolboxService is not null)
+ {
+ List? toolsToAdd = null;
+
+ if (this._toolboxService.Tools.Count > 0)
+ {
+ toolsToAdd = [.. this._toolboxService.Tools];
+ }
+
+ var markers = InputConverter.ReadMcpToolboxMarkers(request);
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ string? resolutionError = null;
+
+ foreach (var (name, version) in markers)
+ {
+ if (!seen.Add(name))
+ {
+ continue;
+ }
+
+ IReadOnlyList? toolboxTools = null;
+ try
+ {
+ toolboxTools = await this._toolboxService
+ .GetToolboxToolsAsync(name, version, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (InvalidOperationException ex)
+ {
+ if (this._logger.IsEnabled(LogLevel.Warning))
+ {
+ this._logger.LogWarning(
+ ex,
+ "Foundry toolbox '{ToolboxName}' could not be resolved for response {ResponseId}.",
+ name,
+ context.ResponseId);
+ }
+
+ resolutionError = ex.Message;
+ break;
+ }
+
+ toolsToAdd ??= [];
+ foreach (var t in toolboxTools)
+ {
+ if (!toolsToAdd.Contains(t))
+ {
+ toolsToAdd.Add(t);
+ }
+ }
+ }
+
+ if (resolutionError is not null)
+ {
+ yield return stream.EmitFailed(ResponseErrorCode.ServerError, resolutionError);
+ yield break;
+ }
+
+ if (toolsToAdd?.Count > 0)
+ {
+ chatOptions.Tools = [.. chatOptions.Tools ?? [], .. toolsToAdd];
+ }
+ }
+
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 +194,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler
while (true)
{
bool shutdownDetected = false;
+ McpConsentInfo? consentInfo = null;
ResponseStreamEvent? failedEvent = null;
ResponseStreamEvent? evt = null;
try
@@ -118,6 +206,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 +230,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.ToolboxName,
+ 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/ConsentAwareMcpClientAIFunction.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ConsentAwareMcpClientAIFunction.cs
new file mode 100644
index 0000000000..5f3ec0ed9b
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ConsentAwareMcpClientAIFunction.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 ConsentAwareMcpClientAIFunction : AIFunction
+{
+ private readonly McpClientTool _inner;
+ private readonly string _toolboxName;
+
+ internal ConsentAwareMcpClientAIFunction(McpClientTool inner, string toolboxName)
+ {
+ this._inner = inner;
+ this._toolboxName = toolboxName;
+ }
+
+ 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