mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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
This commit is contained in:
@@ -283,6 +283,9 @@
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj" />
|
||||
</Folder>
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>HostedToolbox</RootNamespace>
|
||||
<AssemblyName>HostedToolbox</AssemblyName>
|
||||
<NoWarn>$(NoWarn);</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For contributors: uses ProjectReference to build against local source -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Foundry Toolbox Agent - A hosted agent that uses Foundry Toolset MCP tools.
|
||||
//
|
||||
// Demonstrates how to register one or more Foundry toolsets so the agent can
|
||||
// call tools provided by the Foundry platform's managed MCP proxy.
|
||||
//
|
||||
// Required environment variables:
|
||||
// AZURE_AI_PROJECT_ENDPOINT - Azure AI Foundry project endpoint
|
||||
// AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-4o)
|
||||
// FOUNDRY_AGENT_TOOLSET_ENDPOINT - Foundry Toolsets proxy base URL
|
||||
// (injected automatically by Foundry platform at runtime)
|
||||
//
|
||||
// Optional:
|
||||
// FOUNDRY_TOOLSET_NAME - Name of the toolset to load (default: my-toolset)
|
||||
// FOUNDRY_AGENT_NAME - Client name reported to MCP server
|
||||
// FOUNDRY_AGENT_VERSION - Client version reported to MCP server
|
||||
// FOUNDRY_AGENT_TOOLSET_FEATURES - Feature flags sent to Foundry proxy via header
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
|
||||
string toolsetName = Environment.GetEnvironmentVariable("FOUNDRY_TOOLSET_NAME") ?? "my-toolset";
|
||||
|
||||
// Use a chained credential: try a temporary dev token first (for local Docker debugging),
|
||||
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production).
|
||||
TokenCredential credential = new ChainedTokenCredential(
|
||||
new DevTemporaryTokenCredential(),
|
||||
new DefaultAzureCredential());
|
||||
|
||||
// ── Create agent ─────────────────────────────────────────────────────────────
|
||||
|
||||
AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
|
||||
.AsAIAgent(
|
||||
model: deploymentName,
|
||||
instructions: """
|
||||
You are a helpful assistant with access to tools provided by the Foundry Toolset.
|
||||
Use the available tools to answer user questions.
|
||||
If a tool is not available for a request, let the user know clearly.
|
||||
""",
|
||||
name: Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-toolbox-agent",
|
||||
description: "Hosted agent backed by Foundry Toolset MCP tools");
|
||||
|
||||
// ── Build the host ────────────────────────────────────────────────────────────
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Register the agent and response handler
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
|
||||
// Register Foundry Toolbox: connects to the MCP proxy at startup and makes tools available.
|
||||
// The toolset name must match a toolset registered in your Foundry project.
|
||||
// When FOUNDRY_AGENT_TOOLSET_ENDPOINT is absent (e.g., in local development without Foundry
|
||||
// infrastructure), startup succeeds without error and no toolbox tools are loaded.
|
||||
builder.Services.AddFoundryToolboxes(toolsetName);
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapFoundryResponses("openai/v1");
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
// ── DevTemporaryTokenCredential ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> 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 ...
|
||||
/// </summary>
|
||||
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<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> new(this.GetAccessToken());
|
||||
|
||||
private AccessToken GetAccessToken()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<AgentFrameworkResponseHandler> _logger;
|
||||
private readonly FoundryToolboxService? _toolboxService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentFrameworkResponseHandler"/> class
|
||||
@@ -28,15 +29,18 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">The service provider for resolving agents.</param>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
/// <param name="toolboxService">Optional Foundry Toolbox service providing MCP tools.</param>
|
||||
public AgentFrameworkResponseHandler(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<AgentFrameworkResponseHandler> logger)
|
||||
ILogger<AgentFrameworkResponseHandler> logger,
|
||||
FoundryToolboxService? toolboxService = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(serviceProvider);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
this._serviceProvider = serviceProvider;
|
||||
this._logger = logger;
|
||||
this._toolboxService = toolboxService;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -92,14 +96,33 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
// 5. Build chat options
|
||||
var chatOptions = InputConverter.ConvertToChatOptions(request);
|
||||
chatOptions.Instructions = request.Instructions;
|
||||
|
||||
// Inject Foundry Toolbox tools when the toolbox service is available
|
||||
if (this._toolboxService is not null)
|
||||
{
|
||||
var toolboxTools = this._toolboxService.Tools;
|
||||
if (toolboxTools.Count > 0)
|
||||
{
|
||||
chatOptions.Tools = [.. chatOptions.Tools ?? [], .. toolboxTools];
|
||||
}
|
||||
}
|
||||
|
||||
var options = new ChatClientAgentRunOptions(chatOptions);
|
||||
|
||||
// 6. Run the agent and convert output
|
||||
// 6. Set up consent context for -32006 OAuth consent interception.
|
||||
// We create a linked CTS so the consent-aware tool wrapper can cancel the agent
|
||||
// run mid-loop when a -32006 error is returned by the proxy. The RequestConsentState
|
||||
// is a shared mutable object that flows via AsyncLocal to the tool wrapper.
|
||||
using var consentCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
var consentState = new RequestConsentState { CancellationSource = consentCts };
|
||||
McpConsentContext.Current.Value = consentState;
|
||||
|
||||
// 7. Run the agent and convert output
|
||||
// NOTE: C# forbids 'yield return' inside a try block that has a catch clause,
|
||||
// and inside catch blocks. We use a flag to defer the yield to outside the try/catch.
|
||||
bool emittedTerminal = false;
|
||||
var enumerator = OutputConverter.ConvertUpdatesToEventsAsync(
|
||||
agent.RunStreamingAsync(messages, session, options: options, cancellationToken: cancellationToken),
|
||||
agent.RunStreamingAsync(messages, session, options: options, cancellationToken: consentCts.Token),
|
||||
stream,
|
||||
cancellationToken).GetAsyncEnumerator(cancellationToken);
|
||||
try
|
||||
@@ -107,6 +130,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
while (true)
|
||||
{
|
||||
bool shutdownDetected = false;
|
||||
McpConsentInfo? consentInfo = null;
|
||||
ResponseStreamEvent? failedEvent = null;
|
||||
ResponseStreamEvent? evt = null;
|
||||
try
|
||||
@@ -118,6 +142,11 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
|
||||
evt = enumerator.Current;
|
||||
}
|
||||
catch (OperationCanceledException) when (!emittedTerminal && consentState.Pending is not null)
|
||||
{
|
||||
// -32006 consent error: the tool wrapper cancelled consentCts and stored consent info.
|
||||
consentInfo = consentState.Pending;
|
||||
}
|
||||
catch (OperationCanceledException) when (context.IsShutdownRequested && !emittedTerminal)
|
||||
{
|
||||
shutdownDetected = true;
|
||||
@@ -137,6 +166,21 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
ex.Message);
|
||||
}
|
||||
|
||||
if (consentInfo is not null)
|
||||
{
|
||||
// Emit mcp_approval_request output item + incomplete for the consent URL.
|
||||
foreach (var approvalEvent in stream.OutputItemMcpApprovalRequest(
|
||||
consentInfo.ToolsetName,
|
||||
consentInfo.ToolName,
|
||||
consentInfo.ConsentUrl))
|
||||
{
|
||||
yield return approvalEvent;
|
||||
}
|
||||
|
||||
yield return stream.EmitIncomplete(reason: null);
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (failedEvent is not null)
|
||||
{
|
||||
yield return failedEvent;
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="AIFunction"/> wrapper around <see cref="McpClientTool"/> that intercepts
|
||||
/// JSON-RPC error -32006 (OAuth consent required) from the Foundry Toolsets proxy and
|
||||
/// propagates it back to <see cref="AgentFrameworkResponseHandler"/> via
|
||||
/// <see cref="McpConsentContext"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When the proxy returns -32006, the consent URL is stored in <see cref="McpConsentContext.Current"/>
|
||||
/// and the per-request <see cref="RequestConsentState.CancellationSource"/> is cancelled. This causes
|
||||
/// <see cref="FunctionInvokingChatClient"/> to stop the tool loop (it guards
|
||||
/// exceptions with <c>when (!ct.IsCancellationRequested)</c>) and surfaces an
|
||||
/// <see cref="System.OperationCanceledException"/> to the handler. The handler then emits the
|
||||
/// <c>mcp_approval_request</c> output item and marks the response as <c>incomplete</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class ConsentAwareMcpClientTool : AIFunction
|
||||
{
|
||||
private readonly McpClientTool _inner;
|
||||
private readonly string _toolsetName;
|
||||
|
||||
internal ConsentAwareMcpClientTool(McpClientTool inner, string toolsetName)
|
||||
{
|
||||
this._inner = inner;
|
||||
this._toolsetName = toolsetName;
|
||||
}
|
||||
|
||||
public override string Name => this._inner.Name;
|
||||
|
||||
public override string Description => this._inner.Description;
|
||||
|
||||
public override JsonElement JsonSchema => this._inner.JsonSchema;
|
||||
|
||||
public override JsonElement? ReturnJsonSchema => this._inner.ReturnJsonSchema;
|
||||
|
||||
public override JsonSerializerOptions JsonSerializerOptions => this._inner.JsonSerializerOptions;
|
||||
|
||||
protected override async ValueTask<object?> InvokeCoreAsync(
|
||||
AIFunctionArguments arguments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await this._inner.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (McpProtocolException ex) when ((int)ex.ErrorCode == -32006)
|
||||
{
|
||||
var state = McpConsentContext.Current.Value;
|
||||
if (state is not null)
|
||||
{
|
||||
state.Pending = new McpConsentInfo(this._toolsetName, this._inner.Name, ex.Message);
|
||||
state.CancellationSource?.Cancel();
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
throw; // fallback if the CT wasn't cancelled for some reason
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Core;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="DelegatingHandler"/> that:
|
||||
/// <list type="bullet">
|
||||
/// <item>Acquires a fresh Azure bearer token (scope: <c>https://cognitiveservices.azure.com/.default</c>) per request.</item>
|
||||
/// <item>Injects the <c>Foundry-Features</c> header from <c>FOUNDRY_AGENT_TOOLSET_FEATURES</c> when non-empty.</item>
|
||||
/// <item>Retries on HTTP 429, 500, 502, and 503 with exponential back-off (max 3 attempts, per spec §7).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
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<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await this._credential
|
||||
.GetTokenAsync(s_tokenContext, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
|
||||
|
||||
if (!string.IsNullOrEmpty(this._featuresHeaderValue))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Foundry-Features", this._featuresHeaderValue);
|
||||
}
|
||||
|
||||
for (int attempt = 0; attempt < MaxRetries; attempt++)
|
||||
{
|
||||
// Clone the request for retries (the original request cannot be sent twice)
|
||||
HttpRequestMessage requestToSend = attempt == 0
|
||||
? request
|
||||
: await CloneRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = await base.SendAsync(requestToSend, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode is not (HttpStatusCode.TooManyRequests
|
||||
or HttpStatusCode.InternalServerError
|
||||
or HttpStatusCode.BadGateway
|
||||
or HttpStatusCode.ServiceUnavailable))
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
response.Dispose();
|
||||
|
||||
if (attempt < MaxRetries - 1)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Final attempt after backoff exhausted — return last response (already disposed above, so resend)
|
||||
return await base.SendAsync(
|
||||
await CloneRequestAsync(request, cancellationToken).ConfigureAwait(false),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<HttpRequestMessage> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Options for Foundry Toolbox MCP integration.
|
||||
/// </summary>
|
||||
public sealed class FoundryToolboxOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the list of toolset names to connect to at startup.
|
||||
/// Each name corresponds to a toolset registered in the Foundry project.
|
||||
/// The platform proxy URL is constructed as:
|
||||
/// <c>{FOUNDRY_AGENT_TOOLSET_ENDPOINT}/{toolsetName}/mcp?api-version={ApiVersion}</c>
|
||||
/// </summary>
|
||||
public IList<string> ToolsetNames { get; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Toolsets API version to use when constructing proxy URLs.
|
||||
/// </summary>
|
||||
public string ApiVersion { get; set; } = "2025-05-01-preview";
|
||||
|
||||
/// <summary>
|
||||
/// For testing only: overrides <c>FOUNDRY_AGENT_TOOLSET_ENDPOINT</c>.
|
||||
/// Not part of the public API.
|
||||
/// </summary>
|
||||
internal string? EndpointOverride { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Core;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ModelContextProtocol.Client;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IHostedService"/> that eagerly connects to the Foundry Toolsets MCP proxy at
|
||||
/// container startup, discovers tools via <c>tools/list</c>, and caches them so they can be
|
||||
/// injected into every <see cref="ChatOptions"/> by
|
||||
/// <see cref="AgentFrameworkResponseHandler"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When <c>FOUNDRY_AGENT_TOOLSET_ENDPOINT</c> is absent the service starts without error and returns
|
||||
/// an empty tool list, keeping the container healthy per spec §2.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Initialization is performed in <see cref="StartAsync"/> so the readiness probe is only satisfied
|
||||
/// after all configured toolsets are connected and their tools discovered (spec §3.1 SHOULD).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
|
||||
{
|
||||
private readonly FoundryToolboxOptions _options;
|
||||
private readonly TokenCredential _credential;
|
||||
private readonly ILogger<FoundryToolboxService> _logger;
|
||||
|
||||
private readonly List<McpClient> _clients = [];
|
||||
private readonly List<HttpClient> _httpClients = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cached list of <see cref="AITool"/> instances discovered from all connected toolsets.
|
||||
/// Always non-null after startup; returns an empty list when no toolset endpoint is configured.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AITool> Tools { get; private set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="FoundryToolboxService"/>.
|
||||
/// </summary>
|
||||
public FoundryToolboxService(
|
||||
IOptions<FoundryToolboxOptions> options,
|
||||
TokenCredential credential,
|
||||
ILogger<FoundryToolboxService>? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(credential);
|
||||
|
||||
this._options = options.Value;
|
||||
this._credential = credential;
|
||||
this._logger = logger ?? NullLogger<FoundryToolboxService>.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var endpoint = this._options.EndpointOverride
|
||||
?? Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT");
|
||||
|
||||
if (string.IsNullOrEmpty(endpoint))
|
||||
{
|
||||
this._logger.LogInformation("FOUNDRY_AGENT_TOOLSET_ENDPOINT is not set; toolbox support is disabled.");
|
||||
this.Tools = [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._options.ToolsetNames.Count == 0)
|
||||
{
|
||||
this._logger.LogInformation("No toolset names configured; toolbox support is disabled.");
|
||||
this.Tools = [];
|
||||
return;
|
||||
}
|
||||
|
||||
var featuresHeader = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_FEATURES");
|
||||
var agentName = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_NAME") ?? "hosted-agent";
|
||||
var agentVersion = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_VERSION") ?? "1.0.0";
|
||||
|
||||
var allTools = new List<AITool>();
|
||||
|
||||
// Deduplicate toolset names to avoid duplicate MCP clients and ambiguous tool exposure
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var toolsetName in this._options.ToolsetNames)
|
||||
{
|
||||
if (!seen.Add(toolsetName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var proxyUrl = $"{endpoint.TrimEnd('/')}/{toolsetName}/mcp?api-version={this._options.ApiVersion}";
|
||||
|
||||
if (this._logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
this._logger.LogInformation("Connecting to toolset '{ToolsetName}' at {ProxyUrl}.", toolsetName, proxyUrl);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var handler = new FoundryToolboxBearerTokenHandler(this._credential, featuresHeader)
|
||||
{
|
||||
InnerHandler = new HttpClientHandler()
|
||||
};
|
||||
|
||||
var httpClient = new HttpClient(handler);
|
||||
this._httpClients.Add(httpClient);
|
||||
|
||||
var transportOptions = new HttpClientTransportOptions
|
||||
{
|
||||
Endpoint = new Uri(proxyUrl),
|
||||
Name = toolsetName,
|
||||
};
|
||||
|
||||
var transport = new HttpClientTransport(transportOptions, httpClient);
|
||||
|
||||
var clientOptions = new McpClientOptions
|
||||
{
|
||||
ClientInfo = new()
|
||||
{
|
||||
Name = agentName,
|
||||
Version = agentVersion
|
||||
}
|
||||
};
|
||||
|
||||
var client = await McpClient.CreateAsync(
|
||||
transport,
|
||||
clientOptions,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
this._clients.Add(client);
|
||||
|
||||
var tools = await client.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (this._logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
this._logger.LogInformation(
|
||||
"Toolset '{ToolsetName}': discovered {ToolCount} tool(s).",
|
||||
toolsetName,
|
||||
tools.Count);
|
||||
}
|
||||
|
||||
foreach (var tool in tools)
|
||||
{
|
||||
allTools.Add(new ConsentAwareMcpClientTool(tool, toolsetName));
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
this._logger.LogError(
|
||||
ex,
|
||||
"Failed to connect to toolset '{ToolsetName}'. Tools from this toolset will not be available.",
|
||||
toolsetName);
|
||||
}
|
||||
}
|
||||
|
||||
this.Tools = allTools;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (var client in this._clients)
|
||||
{
|
||||
await client.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
this._clients.Clear();
|
||||
|
||||
foreach (var httpClient in this._httpClients)
|
||||
{
|
||||
httpClient.Dispose();
|
||||
}
|
||||
|
||||
this._httpClients.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Carries OAuth consent information for a single tool call that returned JSON-RPC error -32006.
|
||||
/// </summary>
|
||||
/// <param name="ToolsetName">The toolset name that owns the tool.</param>
|
||||
/// <param name="ToolName">Fully-qualified tool name (e.g., <c>logicapps.send_email</c>).</param>
|
||||
/// <param name="ConsentUrl">The OAuth consent URL the user must visit.</param>
|
||||
internal sealed record McpConsentInfo(string ToolsetName, string ToolName, string ConsentUrl);
|
||||
|
||||
/// <summary>
|
||||
/// Per-request mutable state shared between <see cref="ConsentAwareMcpClientTool"/> (child context)
|
||||
/// and <see cref="AgentFrameworkResponseHandler"/> (parent context) via <see cref="McpConsentContext.Current"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Because <see cref="AsyncLocal{T}"/> 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.
|
||||
/// </remarks>
|
||||
internal sealed class RequestConsentState
|
||||
{
|
||||
/// <summary>Consent information set by the tool wrapper when -32006 is detected.</summary>
|
||||
internal McpConsentInfo? Pending { get; set; }
|
||||
|
||||
/// <summary>The linked CTS to cancel when consent is required.</summary>
|
||||
internal CancellationTokenSource? CancellationSource { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thread-static (AsyncLocal) context that enables <see cref="ConsentAwareMcpClientTool"/>
|
||||
/// to signal a consent error back to <see cref="AgentFrameworkResponseHandler"/> through the
|
||||
/// <see cref="FunctionInvokingChatClient"/> tool loop.
|
||||
/// </summary>
|
||||
internal static class McpConsentContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Holds the shared <see cref="RequestConsentState"/> for the current request.
|
||||
/// Set once by the handler; read and mutated by the tool wrapper.
|
||||
/// </summary>
|
||||
internal static readonly AsyncLocal<RequestConsentState?> Current = new();
|
||||
}
|
||||
@@ -4,6 +4,8 @@ using System;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
@@ -97,6 +99,59 @@ public static class FoundryHostingExtensions
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the Foundry Toolbox service, which eagerly connects to the Foundry Toolsets
|
||||
/// MCP proxy at startup and provides MCP tools to <see cref="AgentFrameworkResponseHandler"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each string in <paramref name="toolsetNames"/> is a toolset name registered in the Foundry
|
||||
/// project. The proxy URL per toolset is constructed as:
|
||||
/// <c>{FOUNDRY_AGENT_TOOLSET_ENDPOINT}/{toolsetName}/mcp?api-version=2025-05-01-preview</c>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When <c>FOUNDRY_AGENT_TOOLSET_ENDPOINT</c> is absent, startup succeeds without error and
|
||||
/// no tools are loaded (the container remains healthy per spec §2).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Example:
|
||||
/// <code>
|
||||
/// builder.Services.AddFoundryToolboxes("my-tools", "another-toolset");
|
||||
/// </code>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="toolsetNames">Names of the Foundry toolsets to connect to.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddFoundryToolboxes(
|
||||
this IServiceCollection services,
|
||||
params string[] toolsetNames)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.Configure<FoundryToolboxOptions>(opt =>
|
||||
{
|
||||
foreach (var name in toolsetNames)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
opt.ToolsetNames.Add(name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Register DefaultAzureCredential as the default TokenCredential if not already registered
|
||||
services.TryAddSingleton<TokenCredential>(_ => new DefaultAzureCredential());
|
||||
|
||||
// Register FoundryToolboxService as a singleton so it can be injected into the handler
|
||||
services.TryAddSingleton<FoundryToolboxService>();
|
||||
|
||||
// Add it as a hosted service so StartAsync is called before the app starts serving requests
|
||||
services.AddHostedService(sp => sp.GetRequiredService<FoundryToolboxService>());
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps the Responses API routes for the agent-framework handler to the endpoint routing pipeline.
|
||||
/// </summary>
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
|
||||
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETCoreApp'">
|
||||
<PackageReference Include="Azure.AI.AgentServer.Responses" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user