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 @@ + +