From d30cd927b036527cff92d4eea4850220154b5e23 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Mon, 20 Apr 2026 12:13:40 -0700 Subject: [PATCH] .NET: Hosted agents toolbox support (#5368) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add Foundry Toolbox (MCP) support to AgentFrameworkResponseHandler 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 * Rename toolset to toolbox in user-facing API; rename ConsentAwareMcpClientTool to ConsentAwareMcpClientAIFunction * Add HostedMcpToolboxAITool for client-selectable Foundry toolboxes Introduces HostedMcpToolboxAITool, a marker tool subclassing HostedMcpServerTool that rides the OpenAI Responses 'mcp' wire format to let clients request a specific Foundry toolbox per request. - New FoundryAITool.CreateHostedMcpToolbox(name, version?) factory. - FoundryToolboxOptions.StrictMode (default true) rejects unregistered toolboxes; set to false to allow lazy-open on first use. - FoundryToolboxService.GetToolboxToolsAsync(name, version?) resolves cached or lazy-opened MCP tools. - AgentFrameworkResponseHandler parses request.Tools for foundry-toolbox://name[?version=v] markers and injects resolved tools per request, merging with pre-registered ones. - Unit tests for marker parsing and strict-mode resolution. * Bump Azure.AI.Projects to 2.1.0-alpha; add ToolboxRecord/ToolboxVersion factory overloads + tests * Fix PR review issues: retry off-by-one, URI encoding, docs, tests, build - Fix off-by-one in FoundryToolboxBearerTokenHandler retry loop (4 attempts → 3) - URI-encode version parameter in HostedMcpToolboxAITool.BuildAddress - Add XML doc clarifying version pinning is reserved for future use - Add comment clarifying AddHostedService deduplication safety - Fix DevTemporaryTokenCredential expiry to use DateTimeOffset.MaxValue - Fix AgentCard ambiguity in A2AServer sample with using alias - Add 18 new unit tests for retry handler and ReadMcpToolboxMarkers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Co-authored-by: alliscode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/Directory.Packages.props | 2 +- dotnet/agent-framework-dotnet.slnx | 3 + dotnet/nuget.config | 6 + .../Hosted-Toolbox/HostedToolbox.csproj | 30 ++ .../responses/Hosted-Toolbox/Program.cs | 113 ++++++++ .../A2AServer/HostAgentFactory.cs | 1 + .../FoundryAITool.cs | 44 +++ .../HostedMcpToolboxAITool.cs | 156 +++++++++++ .../Hosting/AgentFrameworkResponseHandler.cs | 114 +++++++- .../ConsentAwareMcpClientAIFunction.cs | 70 +++++ .../FoundryToolboxBearerTokenHandler.cs | 109 ++++++++ .../Hosting/FoundryToolboxOptions.cs | 40 +++ .../Hosting/FoundryToolboxService.cs | 263 ++++++++++++++++++ .../Hosting/InputConverter.cs | 31 +++ .../Hosting/McpConsentContext.cs | 45 +++ .../Hosting/ServiceCollectionExtensions.cs | 71 +++++ .../Microsoft.Agents.AI.Foundry.csproj | 2 + .../HostedMcpToolboxAIToolTests.cs | 163 +++++++++++ .../FoundryToolboxBearerTokenHandlerTests.cs | 185 ++++++++++++ .../Hosting/FoundryToolboxServiceTests.cs | 69 +++++ .../Hosting/InputConverterTests.cs | 96 +++++++ 21 files changed, 1609 insertions(+), 4 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/HostedMcpToolboxAITool.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ConsentAwareMcpClientAIFunction.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 create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/HostedMcpToolboxAIToolTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryToolboxBearerTokenHandlerTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryToolboxServiceTests.cs 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 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._toolboxName, 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..d345297276 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxBearerTokenHandler.cs @@ -0,0 +1,109 @@ +// 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); + } + + // MaxRetries is the total number of attempts (not additional retries after the first). + 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; + } + + // Last attempt exhausted — return the error response as-is. + if (attempt == MaxRetries - 1) + { + return response; + } + + response.Dispose(); + + await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken) + .ConfigureAwait(false); + } + + // Unreachable when MaxRetries > 0, but satisfies the compiler. + throw new InvalidOperationException("Retry loop completed without returning a response."); + } + + 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..79ea9cc7b7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxOptions.cs @@ -0,0 +1,40 @@ +// 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 toolbox names to connect to at startup. + /// Each name corresponds to a toolbox registered in the Foundry project. + /// The platform proxy URL is constructed as: + /// {FOUNDRY_AGENT_TOOLSET_ENDPOINT}/{toolboxName}/mcp?api-version={ApiVersion} + /// + public IList ToolboxNames { get; } = []; + + /// + /// Gets or sets the Toolsets API version to use when constructing proxy URLs. + /// + public string ApiVersion { get; set; } = "2025-05-01-preview"; + + /// + /// Gets or sets a value indicating whether per-request toolbox markers (referenced via + /// foundry-toolbox:// on the wire) are restricted to toolboxes pre-registered + /// via . When (the default), a request + /// that references an unknown toolbox is rejected. When , the + /// server lazily opens an MCP connection for the referenced toolbox on first use and + /// caches it. + /// + public bool StrictMode { get; set; } = true; + + /// + /// 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..8ebde63879 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/FoundryToolboxService.cs @@ -0,0 +1,263 @@ +// 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 Toolboxes 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 +/// no tools are registered, keeping the container healthy per spec §2. +/// +/// +/// Startup eagerly connects to every name in . +/// Beyond those, per-request toolbox markers (see ) are +/// resolved at request time through . Unknown toolboxes are +/// rejected when is and +/// lazily connected otherwise. +/// +/// +public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable +{ + private readonly FoundryToolboxOptions _options; + private readonly TokenCredential _credential; + private readonly ILogger _logger; + + private readonly Dictionary _toolboxes = new(StringComparer.OrdinalIgnoreCase); + private readonly SemaphoreSlim _lazyOpenLock = new(1, 1); + + private string? _resolvedEndpoint; + private string? _featuresHeader; + private string _agentName = "hosted-agent"; + private string _agentVersion = "1.0.0"; + + /// + /// Gets the cached list of instances discovered from all + /// pre-registered toolboxes. Always non-null after startup. + /// + 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) + { + this._resolvedEndpoint = this._options.EndpointOverride + ?? Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT"); + + if (string.IsNullOrEmpty(this._resolvedEndpoint)) + { + this._logger.LogInformation("FOUNDRY_AGENT_TOOLSET_ENDPOINT is not set; toolbox support is disabled."); + this.Tools = []; + return; + } + + this._featuresHeader = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_FEATURES"); + this._agentName = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_NAME") ?? "hosted-agent"; + this._agentVersion = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_VERSION") ?? "1.0.0"; + + if (this._options.ToolboxNames.Count == 0) + { + this._logger.LogInformation("No pre-registered toolbox names configured."); + this.Tools = []; + return; + } + + var allTools = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var toolboxName in this._options.ToolboxNames) + { + if (!seen.Add(toolboxName)) + { + continue; + } + + try + { + var cached = await this.OpenToolboxAsync(toolboxName, version: null, cancellationToken).ConfigureAwait(false); + this._toolboxes[toolboxName] = cached; + allTools.AddRange(cached.Tools); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + this._logger.LogError( + ex, + "Failed to connect to toolbox '{ToolboxName}'. Tools from this toolbox will not be available.", + toolboxName); + } + } + + this.Tools = allTools; + } + + /// + /// Resolves the tools for a per-request toolbox marker. Returns cached tools when the + /// toolbox has already been opened; otherwise honors + /// to either reject or lazily open it. + /// + /// The Foundry toolbox name from the marker. + /// + /// Optional pinned version. Currently reserved for future use — version-specific routing is + /// handled server-side by the Foundry proxy. This parameter is accepted for forward compatibility + /// but does not affect the proxy URL used to connect to the toolbox. + /// + /// The request cancellation token. + /// + /// Thrown when the toolbox is not pre-registered and + /// is , or when the toolbox endpoint is not configured. + /// + public async ValueTask> GetToolboxToolsAsync( + string toolboxName, + string? version, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(toolboxName); + + if (this._toolboxes.TryGetValue(toolboxName, out var cached)) + { + return cached.Tools; + } + + if (this._options.StrictMode) + { + throw new InvalidOperationException( + $"Toolbox '{toolboxName}' is not pre-registered via AddFoundryToolboxes(...). " + + $"Either register it at startup or set {nameof(FoundryToolboxOptions.StrictMode)}=false to allow lazy resolution."); + } + + if (string.IsNullOrEmpty(this._resolvedEndpoint)) + { + throw new InvalidOperationException( + $"Cannot resolve toolbox '{toolboxName}': FOUNDRY_AGENT_TOOLSET_ENDPOINT is not set."); + } + + await this._lazyOpenLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + // Double-check after acquiring the lock to avoid duplicate opens under concurrency. + if (this._toolboxes.TryGetValue(toolboxName, out cached)) + { + return cached.Tools; + } + + cached = await this.OpenToolboxAsync(toolboxName, version, cancellationToken).ConfigureAwait(false); + this._toolboxes[toolboxName] = cached; + return cached.Tools; + } + finally + { + this._lazyOpenLock.Release(); + } + } + + private async Task OpenToolboxAsync( + string toolboxName, + string? version, + CancellationToken cancellationToken) + { + var proxyUrl = $"{this._resolvedEndpoint!.TrimEnd('/')}/{toolboxName}/mcp?api-version={this._options.ApiVersion}"; + + if (this._logger.IsEnabled(LogLevel.Information)) + { + this._logger.LogInformation("Connecting to toolbox '{ToolboxName}' at {ProxyUrl}.", toolboxName, proxyUrl); + } + + var handler = new FoundryToolboxBearerTokenHandler(this._credential, this._featuresHeader) + { + InnerHandler = new HttpClientHandler() + }; + + var httpClient = new HttpClient(handler); + + var transportOptions = new HttpClientTransportOptions + { + Endpoint = new Uri(proxyUrl), + Name = toolboxName, + }; + + var transport = new HttpClientTransport(transportOptions, httpClient); + + var clientOptions = new McpClientOptions + { + ClientInfo = new() + { + Name = this._agentName, + Version = this._agentVersion + } + }; + + var client = await McpClient.CreateAsync( + transport, + clientOptions, + cancellationToken: cancellationToken).ConfigureAwait(false); + + var mcpTools = await client.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + + if (this._logger.IsEnabled(LogLevel.Information)) + { + this._logger.LogInformation( + "Toolbox '{ToolboxName}': discovered {ToolCount} tool(s).", + toolboxName, + mcpTools.Count); + } + + var wrapped = new List(mcpTools.Count); + foreach (var tool in mcpTools) + { + wrapped.Add(new ConsentAwareMcpClientAIFunction(tool, toolboxName)); + } + + _ = version; // reserved for future version-specific routing; currently handled server-side by the proxy. + + return new CachedToolbox(client, httpClient, wrapped); + } + + /// + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public async ValueTask DisposeAsync() + { + foreach (var cached in this._toolboxes.Values) + { + await cached.Client.DisposeAsync().ConfigureAwait(false); + cached.HttpClient.Dispose(); + } + + this._toolboxes.Clear(); + this._lazyOpenLock.Dispose(); + } + + private sealed record CachedToolbox(McpClient Client, HttpClient HttpClient, IReadOnlyList Tools); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InputConverter.cs index 1d8be8f590..cc97049ae9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InputConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InputConverter.cs @@ -97,6 +97,37 @@ internal static class InputConverter }; } + /// + /// Extracts any Foundry Toolbox markers (foundry-toolbox://) from the request's + /// MCP tool entries so the handler can resolve them server-side. + /// + /// The create response request. + /// A list of (name, optional version) pairs, one per detected marker. Never . + public static List<(string Name, string? Version)> ReadMcpToolboxMarkers(CreateResponse request) + { + var markers = new List<(string Name, string? Version)>(); + + if (request.Tools is null) + { + return markers; + } + + foreach (var tool in request.Tools) + { + if (tool is not MCPTool mcp || mcp.ServerUrl is null) + { + continue; + } + + if (HostedMcpToolboxAITool.TryParseToolboxAddress(mcp.ServerUrl.ToString(), out var name, out var version)) + { + markers.Add((name!, version)); + } + } + + return markers; + } + private static ChatMessage? ConvertInputItemToMessage(Item item) { return item switch 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..aba28a5314 --- /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 toolbox 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 ToolboxName, 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 0cfbf7a40e..4192599bd0 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,75 @@ public static class FoundryHostingExtensions return services; } + /// + /// Registers the Foundry Toolbox service, which eagerly connects to the Foundry Toolboxes + /// MCP proxy at startup and provides MCP tools to . + /// + /// + /// + /// Each string in is a toolbox name registered in the Foundry + /// project. The proxy URL per toolbox is constructed as: + /// {FOUNDRY_AGENT_TOOLSET_ENDPOINT}/{toolboxName}/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-toolbox", "another-toolbox"); + /// + /// + /// + /// The service collection. + /// Names of the Foundry toolboxes to connect to. + /// The service collection for chaining. + public static IServiceCollection AddFoundryToolboxes( + this IServiceCollection services, + params string[] toolboxNames) + => services.AddFoundryToolboxes(configureOptions: null, toolboxNames); + + /// + /// Registers the Foundry Toolbox service with additional options configuration. + /// + /// The service collection. + /// Callback to further configure (e.g. set ). + /// Names of the Foundry toolboxes to pre-register at startup. + /// The service collection for chaining. + public static IServiceCollection AddFoundryToolboxes( + this IServiceCollection services, + Action? configureOptions, + params string[] toolboxNames) + { + ArgumentNullException.ThrowIfNull(services); + + services.Configure(opt => + { + foreach (var name in toolboxNames) + { + if (!string.IsNullOrWhiteSpace(name)) + { + opt.ToolboxNames.Add(name); + } + } + + configureOptions?.Invoke(opt); + }); + + // 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(); + + // AddHostedService uses TryAddEnumerable internally, so calling AddFoundryToolboxes + // multiple times will not invoke StartAsync twice on the same singleton. + 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 @@ + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/HostedMcpToolboxAIToolTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/HostedMcpToolboxAIToolTests.cs new file mode 100644 index 0000000000..640ef69008 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/HostedMcpToolboxAIToolTests.cs @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Foundry.UnitTests; + +public class HostedMcpToolboxAIToolTests +{ + [Fact] + public void Ctor_NameOnly_BuildsMarkerAddress() + { + var tool = new HostedMcpToolboxAITool("my-toolbox"); + + Assert.Equal("my-toolbox", tool.ToolboxName); + Assert.Null(tool.Version); + Assert.Equal("my-toolbox", tool.ServerName); + Assert.Equal("foundry-toolbox://my-toolbox", tool.ServerAddress); + Assert.Equal("mcp", tool.Name); + } + + [Fact] + public void Ctor_WithVersion_IncludesVersionQuery() + { + var tool = new HostedMcpToolboxAITool("my-toolbox", "v3"); + + Assert.Equal("v3", tool.Version); + Assert.Equal("foundry-toolbox://my-toolbox?version=v3", tool.ServerAddress); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Ctor_InvalidName_Throws(string? name) + { + Assert.ThrowsAny(() => new HostedMcpToolboxAITool(name!)); + } + + [Fact] + public void TryParseToolboxAddress_NameOnly_ReturnsTrue() + { + var ok = HostedMcpToolboxAITool.TryParseToolboxAddress( + "foundry-toolbox://my-toolbox", out var name, out var version); + + Assert.True(ok); + Assert.Equal("my-toolbox", name); + Assert.Null(version); + } + + [Fact] + public void TryParseToolboxAddress_WithVersion_ExtractsVersion() + { + var ok = HostedMcpToolboxAITool.TryParseToolboxAddress( + "foundry-toolbox://my-toolbox?version=v3", out var name, out var version); + + Assert.True(ok); + Assert.Equal("my-toolbox", name); + Assert.Equal("v3", version); + } + + [Theory] + [InlineData("https://example.com/mcp")] + [InlineData("not-a-url")] + [InlineData("")] + [InlineData(null)] + public void TryParseToolboxAddress_NonMarker_ReturnsFalse(string? address) + { + var ok = HostedMcpToolboxAITool.TryParseToolboxAddress(address, out var name, out var version); + + Assert.False(ok); + Assert.Null(name); + Assert.Null(version); + } + + [Fact] + public void TryParseToolboxAddress_RoundTripsFromBuild() + { + var address = HostedMcpToolboxAITool.BuildAddress("box", "2025-06-01"); + + var ok = HostedMcpToolboxAITool.TryParseToolboxAddress(address, out var name, out var version); + + Assert.True(ok); + Assert.Equal("box", name); + Assert.Equal("2025-06-01", version); + } + + [Fact] + public void FoundryAITool_CreateHostedMcpToolbox_ReturnsMarker() + { + var tool = FoundryAITool.CreateHostedMcpToolbox("my-toolbox", "v1"); + + var marker = Assert.IsType(tool); + Assert.Equal("my-toolbox", marker.ToolboxName); + Assert.Equal("v1", marker.Version); + } + + [Fact] + public void FoundryAITool_CreateHostedMcpToolbox_FromToolboxRecord_UsesNameAndDefaultVersion() + { + var record = Azure.AI.Projects.Agents.ProjectsAgentsModelFactory.ToolboxRecord( + id: "tbx-123", + name: "calendar-tools", + defaultVersion: "v2"); + + var tool = FoundryAITool.CreateHostedMcpToolbox(record); + + var marker = Assert.IsType(tool); + Assert.Equal("calendar-tools", marker.ToolboxName); + Assert.Equal("v2", marker.Version); + Assert.Equal("foundry-toolbox://calendar-tools?version=v2", marker.ServerAddress); + } + + [Fact] + public void FoundryAITool_CreateHostedMcpToolbox_FromToolboxRecord_NullDefaultVersionOmitsQuery() + { + var record = Azure.AI.Projects.Agents.ProjectsAgentsModelFactory.ToolboxRecord( + id: "tbx-abc", + name: "finance-tools", + defaultVersion: null); + + var tool = FoundryAITool.CreateHostedMcpToolbox(record); + + var marker = Assert.IsType(tool); + Assert.Equal("finance-tools", marker.ToolboxName); + Assert.Null(marker.Version); + Assert.Equal("foundry-toolbox://finance-tools", marker.ServerAddress); + } + + [Fact] + public void FoundryAITool_CreateHostedMcpToolbox_FromToolboxRecord_Null_Throws() + { + Assert.Throws( + () => FoundryAITool.CreateHostedMcpToolbox((Azure.AI.Projects.Agents.ToolboxRecord)null!)); + } + + [Fact] + public void FoundryAITool_CreateHostedMcpToolbox_FromToolboxVersion_UsesNameAndVersion() + { + var version = Azure.AI.Projects.Agents.ProjectsAgentsModelFactory.ToolboxVersion( + metadata: null, + id: "ver-1", + name: "hr-tools", + version: "2025-09-01", + description: "HR toolbox", + createdAt: DateTimeOffset.UtcNow, + tools: null, + policies: null); + + var tool = FoundryAITool.CreateHostedMcpToolbox(version); + + var marker = Assert.IsType(tool); + Assert.Equal("hr-tools", marker.ToolboxName); + Assert.Equal("2025-09-01", marker.Version); + Assert.Equal("foundry-toolbox://hr-tools?version=2025-09-01", marker.ServerAddress); + } + + [Fact] + public void FoundryAITool_CreateHostedMcpToolbox_FromToolboxVersion_Null_Throws() + { + Assert.Throws( + () => FoundryAITool.CreateHostedMcpToolbox((Azure.AI.Projects.Agents.ToolboxVersion)null!)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryToolboxBearerTokenHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryToolboxBearerTokenHandlerTests.cs new file mode 100644 index 0000000000..cea48d8eb0 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryToolboxBearerTokenHandlerTests.cs @@ -0,0 +1,185 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Microsoft.Agents.AI.Foundry.Hosting; +using Moq; + +namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; + +public class FoundryToolboxBearerTokenHandlerTests +{ + private const string FakeToken = "test-bearer-token"; + + private static Mock CreateMockCredential() + { + var mock = new Mock(); + mock.Setup(c => c.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new AccessToken(FakeToken, DateTimeOffset.UtcNow.AddHours(1))); + return mock; + } + + private static (FoundryToolboxBearerTokenHandler Handler, CountingHandler Inner) CreateHandlerPair( + Mock? credential = null, + string? featuresHeader = null, + HttpStatusCode statusCode = HttpStatusCode.OK) + { + credential ??= CreateMockCredential(); + var inner = new CountingHandler(statusCode); + var handler = new FoundryToolboxBearerTokenHandler(credential.Object, featuresHeader) + { + InnerHandler = inner + }; + return (handler, inner); + } + + [Fact] + public async Task SendAsync_InjectsBearerTokenAsync() + { + var (handler, _) = CreateHandlerPair(); + using var invoker = new HttpMessageInvoker(handler); + + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api"); + using var response = await invoker.SendAsync(request, CancellationToken.None); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Bearer", request.Headers.Authorization?.Scheme); + Assert.Equal(FakeToken, request.Headers.Authorization?.Parameter); + } + + [Fact] + public async Task SendAsync_InjectsFoundryFeaturesHeaderAsync() + { + var (handler, _) = CreateHandlerPair(featuresHeader: "feature1,feature2"); + using var invoker = new HttpMessageInvoker(handler); + + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api"); + using var response = await invoker.SendAsync(request, CancellationToken.None); + + Assert.True(request.Headers.TryGetValues("Foundry-Features", out var values)); + Assert.Contains("feature1,feature2", values); + } + + [Fact] + public async Task SendAsync_OmitsFeaturesHeaderWhenNullAsync() + { + var (handler, _) = CreateHandlerPair(featuresHeader: null); + using var invoker = new HttpMessageInvoker(handler); + + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api"); + using var response = await invoker.SendAsync(request, CancellationToken.None); + + Assert.False(request.Headers.Contains("Foundry-Features")); + } + + [Theory] + [InlineData(HttpStatusCode.OK)] + [InlineData(HttpStatusCode.Created)] + [InlineData(HttpStatusCode.BadRequest)] + [InlineData(HttpStatusCode.NotFound)] + public async Task SendAsync_NonRetryableStatusCode_ReturnsImmediatelyAsync(HttpStatusCode statusCode) + { + var (handler, inner) = CreateHandlerPair(statusCode: statusCode); + using var invoker = new HttpMessageInvoker(handler); + + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api"); + using var response = await invoker.SendAsync(request, CancellationToken.None); + + Assert.Equal(statusCode, response.StatusCode); + Assert.Equal(1, inner.CallCount); + } + + [Theory] + [InlineData(HttpStatusCode.TooManyRequests)] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.BadGateway)] + [InlineData(HttpStatusCode.ServiceUnavailable)] + public async Task SendAsync_RetryableStatusCode_RetriesMaxTimesAsync(HttpStatusCode statusCode) + { + var (handler, inner) = CreateHandlerPair(statusCode: statusCode); + using var invoker = new HttpMessageInvoker(handler); + + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api"); + using var response = await invoker.SendAsync(request, CancellationToken.None); + + // MaxRetries is 3, so exactly 3 total attempts (not 4). + Assert.Equal(3, inner.CallCount); + Assert.Equal(statusCode, response.StatusCode); + } + + [Fact] + public async Task SendAsync_RetryableStatusCode_SucceedsOnSecondAttemptAsync() + { + // First call returns 503, second returns 200. + var inner = new SequenceHandler( + HttpStatusCode.ServiceUnavailable, + HttpStatusCode.OK); + + var handler = new FoundryToolboxBearerTokenHandler(CreateMockCredential().Object, null) + { + InnerHandler = inner + }; + using var invoker = new HttpMessageInvoker(handler); + + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api"); + using var response = await invoker.SendAsync(request, CancellationToken.None); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(2, inner.CallCount); + } + + /// + /// A test handler that always returns the configured status code and counts how many times it was called. + /// + private sealed class CountingHandler : HttpMessageHandler + { + private readonly HttpStatusCode _statusCode; + private int _callCount; + + public int CallCount => this._callCount; + + public CountingHandler(HttpStatusCode statusCode) + { + this._statusCode = statusCode; + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Interlocked.Increment(ref this._callCount); + return Task.FromResult(new HttpResponseMessage(this._statusCode)); + } + } + + /// + /// A test handler that returns status codes from a sequence, cycling through them. + /// + private sealed class SequenceHandler : HttpMessageHandler + { + private readonly HttpStatusCode[] _statusCodes; + private int _callCount; + + public int CallCount => this._callCount; + + public SequenceHandler(params HttpStatusCode[] statusCodes) + { + this._statusCodes = statusCodes; + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var index = Interlocked.Increment(ref this._callCount) - 1; + var statusCode = index < this._statusCodes.Length + ? this._statusCodes[index] + : this._statusCodes[^1]; + return Task.FromResult(new HttpResponseMessage(statusCode)); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryToolboxServiceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryToolboxServiceTests.cs new file mode 100644 index 0000000000..24f7433c4e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryToolboxServiceTests.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Extensions.Options; +using Moq; + +namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; + +public class FoundryToolboxServiceTests +{ + [Fact] + public async Task GetToolboxToolsAsync_StrictMode_ThrowsForUnknownToolboxAsync() + { + var options = new FoundryToolboxOptions { StrictMode = true }; + var service = new FoundryToolboxService( + Options.Create(options), + Mock.Of()); + + // Act + Assert: no StartAsync so Tools is empty; unknown name in strict mode throws. + var ex = await Assert.ThrowsAsync( + async () => await service.GetToolboxToolsAsync("missing", version: null, CancellationToken.None)); + + Assert.Contains("missing", ex.Message, StringComparison.Ordinal); + Assert.Contains("StrictMode", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task GetToolboxToolsAsync_NonStrictMode_RequiresEndpointAsync() + { + var options = new FoundryToolboxOptions { StrictMode = false }; + var service = new FoundryToolboxService( + Options.Create(options), + Mock.Of()); + + // Without calling StartAsync, endpoint is not resolved so lazy-open fails clearly. + var ex = await Assert.ThrowsAsync( + async () => await service.GetToolboxToolsAsync("missing", version: null, CancellationToken.None)); + + Assert.Contains("FOUNDRY_AGENT_TOOLSET_ENDPOINT", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task StartAsync_WithoutEndpoint_LeavesToolsEmptyAsync() + { + // Ensure env var is not set (tests may run in any CI environment) + var saved = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT"); + Environment.SetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT", null); + try + { + var options = new FoundryToolboxOptions(); + options.ToolboxNames.Add("any"); + var service = new FoundryToolboxService( + Options.Create(options), + Mock.Of()); + + await service.StartAsync(CancellationToken.None); + + Assert.Empty(service.Tools); + } + finally + { + Environment.SetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT", saved); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/InputConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/InputConverterTests.cs index 491b1400e5..e2f6159a6e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/InputConverterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/InputConverterTests.cs @@ -668,4 +668,100 @@ public class InputConverterTests // Model from the request is intentionally NOT propagated — the hosted agent uses its own model. Assert.Null(options.ModelId); } + + // ── ReadMcpToolboxMarkers tests ────────────────────────────────────────────── + + [Fact] + public void ReadMcpToolboxMarkers_NullTools_ReturnsEmpty() + { + var request = new CreateResponse(); + // Tools defaults to null when not set via JSON deserialization. + + var markers = InputConverter.ReadMcpToolboxMarkers(request); + + Assert.Empty(markers); + } + + [Fact] + public void ReadMcpToolboxMarkers_McpToolWithToolboxAddress_ReturnsMarker() + { + var request = new CreateResponse(); + request.Tools.Add(new MCPTool("test-toolbox") + { + ServerUrl = new Uri("foundry-toolbox://my-toolbox") + }); + + var markers = InputConverter.ReadMcpToolboxMarkers(request); + + Assert.Single(markers); + Assert.Equal("my-toolbox", markers[0].Name); + Assert.Null(markers[0].Version); + } + + [Fact] + public void ReadMcpToolboxMarkers_McpToolWithVersionedAddress_ReturnsNameAndVersion() + { + var request = new CreateResponse(); + request.Tools.Add(new MCPTool("test-toolbox") + { + ServerUrl = new Uri("foundry-toolbox://my-toolbox?version=v3") + }); + + var markers = InputConverter.ReadMcpToolboxMarkers(request); + + Assert.Single(markers); + Assert.Equal("my-toolbox", markers[0].Name); + Assert.Equal("v3", markers[0].Version); + } + + [Fact] + public void ReadMcpToolboxMarkers_McpToolWithNonToolboxUrl_SkipsIt() + { + var request = new CreateResponse(); + request.Tools.Add(new MCPTool("external-mcp") + { + ServerUrl = new Uri("https://example.com/mcp") + }); + + var markers = InputConverter.ReadMcpToolboxMarkers(request); + + Assert.Empty(markers); + } + + [Fact] + public void ReadMcpToolboxMarkers_McpToolWithNullServerUrl_SkipsIt() + { + var request = new CreateResponse(); + request.Tools.Add(new MCPTool("test") { ServerUrl = null }); + + var markers = InputConverter.ReadMcpToolboxMarkers(request); + + Assert.Empty(markers); + } + + [Fact] + public void ReadMcpToolboxMarkers_MixedTools_ReturnsOnlyToolboxMarkers() + { + var request = new CreateResponse(); + request.Tools.Add(new MCPTool("external") + { + ServerUrl = new Uri("https://example.com/mcp") + }); + request.Tools.Add(new MCPTool("toolbox-1") + { + ServerUrl = new Uri("foundry-toolbox://box-a") + }); + request.Tools.Add(new MCPTool("toolbox-2") + { + ServerUrl = new Uri("foundry-toolbox://box-b?version=2025-01") + }); + + var markers = InputConverter.ReadMcpToolboxMarkers(request); + + Assert.Equal(2, markers.Count); + Assert.Equal("box-a", markers[0].Name); + Assert.Null(markers[0].Version); + Assert.Equal("box-b", markers[1].Name); + Assert.Equal("2025-01", markers[1].Version); + } }