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 InvokeCoreAsync( + AIFunctionArguments arguments, + CancellationToken cancellationToken) + { + try + { + return await this._inner.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false); + } + catch (McpProtocolException ex) when ((int)ex.ErrorCode == -32006) + { + var state = McpConsentContext.Current.Value; + if (state is not null) + { + state.Pending = new McpConsentInfo(this._toolsetName, this._inner.Name, ex.Message); + state.CancellationSource?.Cancel(); + } + + cancellationToken.ThrowIfCancellationRequested(); + throw; // fallback if the CT wasn't cancelled for some reason + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxBearerTokenHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxBearerTokenHandler.cs new file mode 100644 index 0000000000..ac873b1e84 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxBearerTokenHandler.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// An that: +/// +/// Acquires a fresh Azure bearer token (scope: https://cognitiveservices.azure.com/.default) per request. +/// Injects the Foundry-Features header from FOUNDRY_AGENT_TOOLSET_FEATURES when non-empty. +/// Retries on HTTP 429, 500, 502, and 503 with exponential back-off (max 3 attempts, per spec §7). +/// +/// +internal sealed class FoundryToolboxBearerTokenHandler : DelegatingHandler +{ + private const int MaxRetries = 3; + private static readonly TokenRequestContext s_tokenContext = + new(["https://cognitiveservices.azure.com/.default"]); + + private readonly TokenCredential _credential; + private readonly string? _featuresHeaderValue; + + internal FoundryToolboxBearerTokenHandler(TokenCredential credential, string? featuresHeaderValue) + { + this._credential = credential; + this._featuresHeaderValue = featuresHeaderValue; + } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var token = await this._credential + .GetTokenAsync(s_tokenContext, cancellationToken) + .ConfigureAwait(false); + + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); + + if (!string.IsNullOrEmpty(this._featuresHeaderValue)) + { + request.Headers.TryAddWithoutValidation("Foundry-Features", this._featuresHeaderValue); + } + + for (int attempt = 0; attempt < MaxRetries; attempt++) + { + // Clone the request for retries (the original request cannot be sent twice) + HttpRequestMessage requestToSend = attempt == 0 + ? request + : await CloneRequestAsync(request, cancellationToken).ConfigureAwait(false); + + var response = await base.SendAsync(requestToSend, cancellationToken).ConfigureAwait(false); + + if (response.StatusCode is not (HttpStatusCode.TooManyRequests + or HttpStatusCode.InternalServerError + or HttpStatusCode.BadGateway + or HttpStatusCode.ServiceUnavailable)) + { + return response; + } + + response.Dispose(); + + if (attempt < MaxRetries - 1) + { + await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken) + .ConfigureAwait(false); + } + } + + // Final attempt after backoff exhausted — return last response (already disposed above, so resend) + return await base.SendAsync( + await CloneRequestAsync(request, cancellationToken).ConfigureAwait(false), + cancellationToken).ConfigureAwait(false); + } + + private static async Task CloneRequestAsync( + HttpRequestMessage original, + CancellationToken cancellationToken) + { + var clone = new HttpRequestMessage(original.Method, original.RequestUri); + + foreach (var header in original.Headers) + { + clone.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + if (original.Content is not null) + { + var contentBytes = await original.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); + clone.Content = new ByteArrayContent(contentBytes); + + foreach (var header in original.Content.Headers) + { + clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + + return clone; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxOptions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxOptions.cs new file mode 100644 index 0000000000..dd06a7f3fb --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxOptions.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Options for Foundry Toolbox MCP integration. +/// +public sealed class FoundryToolboxOptions +{ + /// + /// Gets the list of toolset names to connect to at startup. + /// Each name corresponds to a toolset registered in the Foundry project. + /// The platform proxy URL is constructed as: + /// {FOUNDRY_AGENT_TOOLSET_ENDPOINT}/{toolsetName}/mcp?api-version={ApiVersion} + /// + public IList ToolsetNames { get; } = new List(); + + /// + /// Gets or sets the Toolsets API version to use when constructing proxy URLs. + /// + public string ApiVersion { get; set; } = "2025-05-01-preview"; + + /// + /// For testing only: overrides FOUNDRY_AGENT_TOOLSET_ENDPOINT. + /// Not part of the public API. + /// + internal string? EndpointOverride { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxService.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxService.cs new file mode 100644 index 0000000000..3b96bb8ef0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxService.cs @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Client; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// An that eagerly connects to the Foundry Toolsets MCP proxy at +/// container startup, discovers tools via tools/list, and caches them so they can be +/// injected into every by +/// . +/// +/// +/// +/// When FOUNDRY_AGENT_TOOLSET_ENDPOINT is absent the service starts without error and returns +/// an empty tool list, keeping the container healthy per spec §2. +/// +/// +/// Initialization is performed in so the readiness probe is only satisfied +/// after all configured toolsets are connected and their tools discovered (spec §3.1 SHOULD). +/// +/// +public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable +{ + private readonly FoundryToolboxOptions _options; + private readonly TokenCredential _credential; + private readonly ILogger _logger; + + private readonly List _clients = []; + private readonly List _httpClients = []; + + /// + /// Gets the cached list of instances discovered from all connected toolsets. + /// Always non-null after startup; returns an empty list when no toolset endpoint is configured. + /// + public IReadOnlyList Tools { get; private set; } = []; + + /// + /// Initializes a new instance of . + /// + public FoundryToolboxService( + IOptions options, + TokenCredential credential, + ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(credential); + + this._options = options.Value; + this._credential = credential; + this._logger = logger ?? NullLogger.Instance; + } + + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + var endpoint = this._options.EndpointOverride + ?? Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT"); + + if (string.IsNullOrEmpty(endpoint)) + { + this._logger.LogInformation("FOUNDRY_AGENT_TOOLSET_ENDPOINT is not set; toolbox support is disabled."); + this.Tools = []; + return; + } + + if (this._options.ToolsetNames.Count == 0) + { + this._logger.LogInformation("No toolset names configured; toolbox support is disabled."); + this.Tools = []; + return; + } + + var featuresHeader = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_FEATURES"); + var agentName = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_NAME") ?? "hosted-agent"; + var agentVersion = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_VERSION") ?? "1.0.0"; + + var allTools = new List(); + + // Deduplicate toolset names to avoid duplicate MCP clients and ambiguous tool exposure + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var toolsetName in this._options.ToolsetNames) + { + if (!seen.Add(toolsetName)) + { + continue; + } + + var proxyUrl = $"{endpoint.TrimEnd('/')}/{toolsetName}/mcp?api-version={this._options.ApiVersion}"; + + if (this._logger.IsEnabled(LogLevel.Information)) + { + this._logger.LogInformation("Connecting to toolset '{ToolsetName}' at {ProxyUrl}.", toolsetName, proxyUrl); + } + + try + { + var handler = new FoundryToolboxBearerTokenHandler(this._credential, featuresHeader) + { + InnerHandler = new HttpClientHandler() + }; + + var httpClient = new HttpClient(handler); + this._httpClients.Add(httpClient); + + var transportOptions = new HttpClientTransportOptions + { + Endpoint = new Uri(proxyUrl), + Name = toolsetName, + }; + + var transport = new HttpClientTransport(transportOptions, httpClient); + + var clientOptions = new McpClientOptions + { + ClientInfo = new() + { + Name = agentName, + Version = agentVersion + } + }; + + var client = await McpClient.CreateAsync( + transport, + clientOptions, + cancellationToken: cancellationToken).ConfigureAwait(false); + + this._clients.Add(client); + + var tools = await client.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + + if (this._logger.IsEnabled(LogLevel.Information)) + { + this._logger.LogInformation( + "Toolset '{ToolsetName}': discovered {ToolCount} tool(s).", + toolsetName, + tools.Count); + } + + foreach (var tool in tools) + { + allTools.Add(new ConsentAwareMcpClientTool(tool, toolsetName)); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + this._logger.LogError( + ex, + "Failed to connect to toolset '{ToolsetName}'. Tools from this toolset will not be available.", + toolsetName); + } + } + + this.Tools = allTools; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public async ValueTask DisposeAsync() + { + foreach (var client in this._clients) + { + await client.DisposeAsync().ConfigureAwait(false); + } + + this._clients.Clear(); + + foreach (var httpClient in this._httpClients) + { + httpClient.Dispose(); + } + + this._httpClients.Clear(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/McpConsentContext.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/McpConsentContext.cs new file mode 100644 index 0000000000..4509db95da --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/McpConsentContext.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Carries OAuth consent information for a single tool call that returned JSON-RPC error -32006. +/// +/// The toolset name that owns the tool. +/// Fully-qualified tool name (e.g., logicapps.send_email). +/// The OAuth consent URL the user must visit. +internal sealed record McpConsentInfo(string ToolsetName, string ToolName, string ConsentUrl); + +/// +/// Per-request mutable state shared between (child context) +/// and (parent context) via . +/// +/// +/// Because only flows values DOWN from parent to children, +/// we use a shared reference type so children can mutate it and the parent observes the mutations. +/// +internal sealed class RequestConsentState +{ + /// Consent information set by the tool wrapper when -32006 is detected. + internal McpConsentInfo? Pending { get; set; } + + /// The linked CTS to cancel when consent is required. + internal CancellationTokenSource? CancellationSource { get; set; } +} + +/// +/// Thread-static (AsyncLocal) context that enables +/// to signal a consent error back to through the +/// tool loop. +/// +internal static class McpConsentContext +{ + /// + /// Holds the shared for the current request. + /// Set once by the handler; read and mutated by the tool wrapper. + /// + internal static readonly AsyncLocal Current = new(); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs index 73d032f13b..e95a1a847f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs @@ -4,6 +4,8 @@ using System; using System.Reflection; using System.Threading.Tasks; using Azure.AI.AgentServer.Responses; +using Azure.Core; +using Azure.Identity; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -97,6 +99,59 @@ public static class FoundryHostingExtensions return services; } + /// + /// Registers the Foundry Toolbox service, which eagerly connects to the Foundry Toolsets + /// MCP proxy at startup and provides MCP tools to . + /// + /// + /// + /// Each string in is a toolset name registered in the Foundry + /// project. The proxy URL per toolset is constructed as: + /// {FOUNDRY_AGENT_TOOLSET_ENDPOINT}/{toolsetName}/mcp?api-version=2025-05-01-preview + /// + /// + /// When FOUNDRY_AGENT_TOOLSET_ENDPOINT is absent, startup succeeds without error and + /// no tools are loaded (the container remains healthy per spec §2). + /// + /// + /// Example: + /// + /// builder.Services.AddFoundryToolboxes("my-tools", "another-toolset"); + /// + /// + /// + /// The service collection. + /// Names of the Foundry toolsets to connect to. + /// The service collection for chaining. + public static IServiceCollection AddFoundryToolboxes( + this IServiceCollection services, + params string[] toolsetNames) + { + ArgumentNullException.ThrowIfNull(services); + + services.Configure(opt => + { + foreach (var name in toolsetNames) + { + if (!string.IsNullOrWhiteSpace(name)) + { + opt.ToolsetNames.Add(name); + } + } + }); + + // Register DefaultAzureCredential as the default TokenCredential if not already registered + services.TryAddSingleton(_ => new DefaultAzureCredential()); + + // Register FoundryToolboxService as a singleton so it can be injected into the handler + services.TryAddSingleton(); + + // Add it as a hosted service so StartAsync is called before the app starts serving requests + services.AddHostedService(sp => sp.GetRequiredService()); + + return services; + } + /// /// Maps the Responses API routes for the agent-framework handler to the endpoint routing pipeline. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj index e3c1773941..13f21cdc52 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj @@ -36,6 +36,8 @@ + +