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); + } }