Merge branch 'main' into copilot/bump-dependencies-to-10-6-0

This commit is contained in:
Roger Barreto
2026-06-05 12:46:25 +01:00
committed by GitHub
Unverified
322 changed files with 23778 additions and 4334 deletions
@@ -10,6 +10,11 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient().AddLogging();
builder.Services.AddAGUI();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
@@ -14,6 +14,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
@@ -16,6 +16,11 @@ builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.TypeInfoResolverChain.Add(SampleJsonSerializerContext.Default));
builder.Services.AddAGUI();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
@@ -14,6 +14,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
@@ -10,6 +10,11 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient().AddLogging();
builder.Services.AddAGUI();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
@@ -14,6 +14,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
@@ -27,6 +27,11 @@ builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.TypeInfoResolverChain.Add(ApprovalJsonContext.Default));
builder.Services.AddAGUI();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
app.UseHttpLogging();
@@ -14,6 +14,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
@@ -17,6 +17,11 @@ builder.Services.AddAGUI();
// Configure to listen on port 8888
builder.WebHost.UseUrls("http://localhost:8888");
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
@@ -14,6 +14,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="ModelContextProtocol" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Mcp\Microsoft.Agents.AI.Mcp.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,93 @@
// Copyright (c) Microsoft. All rights reserved.
// Foundry Toolbox MCP Skills.
//
// Uses AgentSkillsProviderBuilder to discover MCP-based skills from a Foundry
// Toolbox endpoint and inject them as AIContextProviders so the agent can
// discover and use them at runtime.
using System.Net.Http.Headers;
using Azure.AI.Projects;
using Azure.Core;
using Azure.Identity;
using Microsoft.Agents.AI;
using ModelContextProtocol.Client;
// --- Configuration ---
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-5.4-mini";
string toolboxMcpServerUrl = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_MCP_SERVER_URL")
?? throw new InvalidOperationException("FOUNDRY_TOOLBOX_MCP_SERVER_URL is not set.");
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
TokenCredential credential = new DefaultAzureCredential();
using var httpClient = new HttpClient(new BearerTokenHandler(credential, "https://ai.azure.com/.default")
{
InnerHandler = new HttpClientHandler(),
});
// --- Connect to the Foundry Toolbox MCP endpoint ---
await using McpClient mcpClient = await McpClient.CreateAsync(
new HttpClientTransport(
new HttpClientTransportOptions
{
Endpoint = new Uri(toolboxMcpServerUrl),
Name = "foundry_toolbox",
TransportMode = HttpTransportMode.StreamableHttp,
AdditionalHeaders = new Dictionary<string, string>
{
["Foundry-Features"] = "Toolboxes=V1Preview",
},
},
httpClient));
// --- Discover MCP-based skills ---
var skillsProvider = new AgentSkillsProviderBuilder()
.UseMcpSkills(mcpClient)
.Build();
// --- Create the agent ---
AIProjectClient aiProjectClient = new(new Uri(endpoint), credential);
AIAgent agent = aiProjectClient.AsAIAgent(
options: new ChatClientAgentOptions
{
Name = "ToolboxMcpSkillsAgent",
ChatOptions = new()
{
ModelId = deploymentName,
Instructions = "You are a helpful assistant. Use available skills to answer the user.",
},
AIContextProviders = [skillsProvider],
});
// --- Interactive prompt ---
Console.Write("User: ");
string? query = Console.ReadLine();
if (string.IsNullOrWhiteSpace(query))
{
Console.WriteLine("No input provided.");
return;
}
Console.WriteLine($"Assistant: {await agent.RunAsync(query)}");
// ---------------------------------------------------------------------------
// DelegatingHandler: attaches a fresh Foundry bearer token to every request
// ---------------------------------------------------------------------------
internal sealed class BearerTokenHandler(TokenCredential credential, string scope) : DelegatingHandler
{
private readonly TokenRequestContext _tokenContext = new([scope]);
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
AccessToken token = await credential.GetTokenAsync(this._tokenContext, cancellationToken).ConfigureAwait(false);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,32 @@
# Foundry Toolbox MCP Skills
This sample uses
`AgentSkillsProviderBuilder` to discover MCP-based skills from a Foundry Toolbox endpoint
and inject them as `AIContextProviders` so the agent can discover and use them at runtime.
## What this sample demonstrates
- Connecting to a Foundry toolbox's MCP endpoint via Streamable HTTP transport
- Injecting a fresh Azure AI bearer token (`https://ai.azure.com/.default`) on every MCP request
- Using `AgentSkillsProviderBuilder.UseMcpSkills(client)` to discover skills from the toolbox
- Injecting the discovered skills into `AIProjectClient.AsAIAgent(...)` via `AIContextProviders`
## Prerequisites
- A Microsoft Foundry project with a toolbox already configured
- The toolbox MCP endpoint must expose `skill://index.json` with `skill-md` entries (SEP-2640). If the resource is absent, the sample runs but the skills provider will be empty.
- Azure CLI installed and authenticated (`az login`)
Set the following environment variables:
```powershell
$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini"
$env:FOUNDRY_TOOLBOX_MCP_SERVER_URL="https://your-foundry-service.services.ai.azure.com/api/projects/your-project/toolboxes/your-toolbox/mcp?api-version=v1"
```
## Run the sample
```powershell
dotnet run
```
@@ -74,6 +74,7 @@ Some samples require extra tool-specific environment variables. See each sample
| [Local MCP](./Agent_Step23_LocalMCP/) | Local MCP client with HTTP transport |
| [Code interpreter file download](./Agent_Step24_CodeInterpreterFileDownload/) | Download container files generated by code interpreter |
| [Foundry toolbox via MCP](./Agent_Step25_FoundryToolboxMcp/) | Use a Foundry Toolbox from a non-hosted agent via its MCP endpoint |
| [Foundry toolbox MCP skills](./Agent_Step26_FoundryToolboxMcpSkills/) | Use a Foundry Toolbox with MCP-based skills discovery (SEP-2640) via AIContextProviders |
## Running the samples
@@ -72,6 +72,95 @@ public static class AnsiEscapes
/// </summary>
public static string ResetAttributes => "\x1b[0m";
/// <summary>
/// Returns the visible (printed) length of a string after stripping ANSI escape sequences.
/// Escape sequences are zero-width on screen but occupy characters in the raw string.
/// </summary>
/// <remarks>
/// This counts UTF-16 code units (chars) rather than terminal display cells. Emoji,
/// combining characters, variation selectors, and East Asian wide characters may be
/// measured incorrectly. For the console harness this is acceptable since content is
/// predominantly ASCII, and emoji are padded with surrounding spaces.
/// </remarks>
public static int VisibleLength(string text)
{
if (string.IsNullOrEmpty(text))
{
return 0;
}
int length = 0;
for (int i = 0; i < text.Length; i++)
{
if (text[i] == '\x1b' && i + 1 < text.Length && text[i + 1] == '[')
{
// Skip the ESC[ and all characters up to and including the final byte (0x400x7E).
i += 2;
while (i < text.Length && text[i] < 0x40)
{
i++;
}
// i now points to the final byte of the escape sequence; the for-loop will advance past it.
}
else if (text[i] != '\n' && text[i] != '\r')
{
length++;
}
}
return length;
}
/// <summary>
/// Counts the number of physical terminal rows a text item will occupy,
/// accounting for both explicit newlines and terminal line wrapping.
/// </summary>
/// <param name="text">The text to measure.</param>
/// <param name="terminalWidth">The terminal width in columns. If &lt;= 0, wrapping is ignored (1 row per logical line).</param>
/// <returns>The number of physical rows the text occupies.</returns>
public static int CountPhysicalLines(string text, int terminalWidth)
{
if (string.IsNullOrEmpty(text))
{
return 0;
}
int physicalLines = 0;
int lineStart = 0;
for (int i = 0; i <= text.Length; i++)
{
if (i == text.Length || text[i] == '\n')
{
if (terminalWidth <= 0)
{
// No wrapping — each logical line is one physical row
physicalLines += 1;
}
else
{
string logicalLine = text[lineStart..i];
int visibleWidth = VisibleLength(logicalLine);
physicalLines += visibleWidth == 0
? 1
: (visibleWidth - 1) / terminalWidth + 1;
}
lineStart = i + 1;
}
}
// If text ends with a newline, don't count the trailing empty line
if (text[text.Length - 1] == '\n')
{
physicalLines--;
}
return physicalLines;
}
private static int ConsoleColorToAnsi(ConsoleColor color) => color switch
{
ConsoleColor.Black => 30,
@@ -23,16 +23,18 @@ public record TextPanelProps : ConsoleReactiveProps
public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiveState>
{
/// <summary>
/// Calculates the height (in lines) needed to render all items.
/// Calculates the height (in lines) needed to render all items,
/// accounting for terminal line wrapping at the specified width.
/// </summary>
/// <param name="items">The items to measure.</param>
/// <returns>The total number of lines all items will occupy.</returns>
public static int CalculateHeight(IReadOnlyList<string> items)
/// <param name="terminalWidth">The terminal width in columns. When 0 or negative, wrapping is ignored.</param>
/// <returns>The total number of physical lines all items will occupy.</returns>
public static int CalculateHeight(IReadOnlyList<string> items, int terminalWidth = 0)
{
int total = 0;
for (int i = 0; i < items.Count; i++)
{
total += CountLines(items[i]);
total += AnsiEscapes.CountPhysicalLines(items[i], terminalWidth);
}
return total;
@@ -47,13 +49,20 @@ public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiv
{
string text = props.Items[i];
string[] lines = text.Split('\n');
int lineCount = CountLines(text);
int itemLineCount = AnsiEscapes.CountPhysicalLines(text, props.Width);
int itemRow = 0;
for (int j = 0; j < lineCount; j++)
for (int j = 0; j < lines.Length && itemRow < itemLineCount; j++)
{
int linePhysicalRows = props.Width > 0
? Math.Max(1, (AnsiEscapes.VisibleLength(lines[j]) - 1) / props.Width + 1)
: 1;
Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y + currentRow));
Console.Write(lines[j]);
currentRow++;
currentRow += linePhysicalRows;
itemRow += linePhysicalRows;
}
}
@@ -66,29 +75,4 @@ public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiv
}
}
}
private static int CountLines(string text)
{
if (string.IsNullOrEmpty(text))
{
return 0;
}
int count = 1;
for (int i = 0; i < text.Length; i++)
{
if (text[i] == '\n')
{
count++;
}
}
// If text ends with a newline, don't count the trailing empty line
if (text[text.Length - 1] == '\n')
{
count--;
}
return count;
}
}
@@ -77,36 +77,12 @@ public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, Te
Console.Write(props.Items[i]);
}
// Calculate the offset from bottom for the start of the new last item
int lastItemLines = CountLines(props.Items[^1]);
// Calculate the offset from bottom for the start of the new last item,
// accounting for terminal line wrapping at the available width.
int lastItemLines = AnsiEscapes.CountPhysicalLines(props.Items[^1], props.Width);
this._lastItemOffsetFromBottom = lastItemLines > 0 ? lastItemLines - 1 : 0;
// Update rendered count
this._renderedCount = props.Items.Count;
}
private static int CountLines(string text)
{
if (string.IsNullOrEmpty(text))
{
return 0;
}
int count = 1;
for (int i = 0; i < text.Length; i++)
{
if (text[i] == '\n')
{
count++;
}
}
// If text ends with a newline, don't count the trailing empty line
if (text[text.Length - 1] == '\n')
{
count--;
}
return count;
}
}
@@ -13,6 +13,11 @@ public abstract class ConsoleReactiveComponent
{
}
/// <summary>
/// Gets the shared render lock across all component types to prevent ANSI escape sequence interleaving.
/// </summary>
protected static object RenderLock { get; } = new();
/// <summary>
/// Gets or sets the component's props as the base <see cref="ConsoleReactiveProps"/> type.
/// Used by parent components to set layout (X, Y, Width, Height) on children without
@@ -40,7 +45,6 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
where TProps : ConsoleReactiveProps
where TState : ConsoleReactiveState
{
private readonly object _renderLock = new();
private TProps? _lastRenderedProps;
private TState? _lastRenderedState;
@@ -74,7 +78,7 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
/// </summary>
public override void Render()
{
lock (this._renderLock)
lock (RenderLock)
{
if (this.Props is null)
{
@@ -97,7 +101,7 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
/// <inheritdoc/>
public override void Invalidate()
{
lock (this._renderLock)
lock (RenderLock)
{
this._lastRenderedProps = default;
this._lastRenderedState = default;
@@ -28,6 +28,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
private int _scrollRegionBottom;
private bool _resizedSinceLastRender = true;
private bool _deactivated;
private BottomPanelMode _lastRenderedBottomPanelMode;
/// <summary>
/// Initializes a new instance of the <see cref="HarnessAppComponent"/> class.
@@ -341,7 +342,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
}
// Calculate queued items panel height
int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems);
int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems, state.ConsoleWidth);
// Build the bottom panel child based on mode
ConsoleReactiveComponent bottomChild;
@@ -406,6 +407,14 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
bottomChild = this._textInput;
}
// When the bottom panel mode changes, the new child must repaint even if its
// props haven't changed — the screen area was overwritten by the previous child.
if (state.Mode != this._lastRenderedBottomPanelMode)
{
bottomChild.Invalidate();
this._lastRenderedBottomPanelMode = state.Mode;
}
var ruleProps = new TopBottomRuleProps
{
Width = state.ConsoleWidth,
@@ -88,16 +88,17 @@ public sealed class PlanningOutputObserver : ConsoleObserver
{
planningResponse = JsonSerializer.Deserialize<PlanningResponse>(collectedText);
}
catch (JsonException ex)
catch (JsonException)
{
await ux.WriteInfoLineAsync($"❌ Failed to parse planning response: {ex.Message}", ConsoleColor.Red);
await ux.WriteInfoLineAsync($"(raw response) {collectedText}", ConsoleColor.DarkYellow);
// JSON parsing failed — fall back to rendering as regular text output.
await ux.WriteTextAsync(collectedText).ConfigureAwait(false);
return null;
}
if (planningResponse is null)
{
await ux.WriteInfoLineAsync("(no structured response from agent)", ConsoleColor.DarkYellow);
// Null result — fall back to rendering as regular text output.
await ux.WriteTextAsync(collectedText).ConfigureAwait(false);
return null;
}
@@ -118,7 +119,8 @@ public sealed class PlanningOutputObserver : ConsoleObserver
return new List<FollowUpAction> { this.BuildApprovalAction(question, session) };
}
await ux.WriteInfoLineAsync($"(unexpected response type: {planningResponse.Type})", ConsoleColor.DarkYellow);
// Unexpected type — fall back to rendering as regular text output.
await ux.WriteTextAsync(collectedText).ConfigureAwait(false);
return null;
}
@@ -2,6 +2,7 @@
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
using System.Text.Json;
using Harness.Shared.Console.Observers;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
@@ -53,9 +54,94 @@ public sealed class OpenAIResponsesErrorObserver : ConsoleObserver
case StreamingResponseIncompleteUpdate incompleteUpdate:
string? reason = incompleteUpdate.Response?.IncompleteStatusDetails?.Reason?.ToString();
string incompleteText = $"⚠️ Response incomplete: {reason ?? "unknown reason"}";
await ux.WriteInfoLineAsync(incompleteText, ConsoleColor.Yellow);
if (string.Equals(reason, "content_filter", StringComparison.OrdinalIgnoreCase))
{
string detail = GetContentFilterDetails(incompleteUpdate);
const string Message = "🛡️ The service's built-in content filter guardrails were triggered and the response was cut short.";
await ux.WriteInfoLineAsync(
string.IsNullOrEmpty(detail) ? Message : $"{Message}\n{detail}",
ConsoleColor.Yellow);
}
else
{
string incompleteText = $"⚠️ Response incomplete: {reason ?? "unknown reason"}";
await ux.WriteInfoLineAsync(incompleteText, ConsoleColor.Yellow);
}
break;
}
}
/// <summary>
/// Extracts content filter details from the serialized response JSON and returns
/// a formatted string showing which specific categories were triggered.
/// Returns <see cref="string.Empty"/> if details cannot be extracted.
/// </summary>
private static string GetContentFilterDetails(StreamingResponseIncompleteUpdate incompleteUpdate)
{
try
{
var data = System.ClientModel.Primitives.ModelReaderWriter.Write(incompleteUpdate);
using var doc = JsonDocument.Parse(data.ToString());
var root = doc.RootElement;
// Navigate into the nested response object if present.
JsonElement responseElement = root.TryGetProperty("response", out var resp) ? resp : root;
if (!responseElement.TryGetProperty("content_filters", out var filtersArray)
|| filtersArray.ValueKind != JsonValueKind.Array)
{
return string.Empty;
}
foreach (var filter in filtersArray.EnumerateArray())
{
if (!filter.TryGetProperty("content_filter_results", out var results)
|| results.ValueKind != JsonValueKind.Object)
{
continue;
}
// Collect category data for aligned output.
var categories = new List<(string Name, bool Filtered, string? Severity)>();
foreach (var category in results.EnumerateObject())
{
if (category.Value.ValueKind != JsonValueKind.Object)
{
continue;
}
bool filtered = category.Value.TryGetProperty("filtered", out var f) && f.GetBoolean();
string? severity = category.Value.TryGetProperty("severity", out var s) ? s.GetString() : null;
categories.Add((category.Name, filtered, severity));
}
// Build all category lines into a single string.
int maxNameLen = categories.Count > 0 ? categories.Max(c => c.Name.Length) : 0;
var lines = new List<string>();
foreach (var (name, filtered, severity) in categories)
{
string paddedName = name.PadRight(maxNameLen);
string icon = filtered ? "❌" : "✅";
string statusText = filtered ? "Filtered " : "Not Filtered";
string severityText = severity is not null ? $" Severity: {severity}" : "";
lines.Add($" {icon} {paddedName} {statusText}{severityText}");
}
if (lines.Count > 0)
{
return string.Join("\n", lines);
}
}
return string.Empty;
}
catch
{
// Parsing not critical — skip silently if it fails.
return string.Empty;
}
}
}
@@ -50,12 +50,16 @@ internal static partial class WorkflowHelper
/// <summary>
/// Executor that starts the concurrent processing by sending messages to the agents.
/// </summary>
private sealed partial class ConcurrentStartExecutor() : Executor("ConcurrentStartExecutor")
[SendsMessage(typeof(List<ChatMessage>))]
[SendsMessage(typeof(TurnToken))]
private sealed partial class ConcurrentStartExecutor()
: Executor("ConcurrentStartExecutor", declareCrossRunShareable: true), IResettableExecutor
{
[MessageHandler]
internal ValueTask RouteMessages(List<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
internal ValueTask RouteMessages(IEnumerable<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
{
return context.SendMessageAsync(messages, cancellationToken: cancellationToken);
List<ChatMessage> payload = messages as List<ChatMessage> ?? messages.ToList();
return context.SendMessageAsync(payload, cancellationToken: cancellationToken);
}
[MessageHandler]
@@ -63,13 +67,16 @@ internal static partial class WorkflowHelper
{
return context.SendMessageAsync(token, cancellationToken: cancellationToken);
}
public ValueTask ResetAsync() => default;
}
/// <summary>
/// Executor that aggregates the results from the concurrent agents.
/// </summary>
[YieldsOutput(typeof(List<ChatMessage>))]
private sealed partial class ConcurrentAggregationExecutor() : Executor<List<ChatMessage>>("ConcurrentAggregationExecutor")
[YieldsOutput(typeof(string))]
private sealed partial class ConcurrentAggregationExecutor() :
Executor<List<ChatMessage>>("ConcurrentAggregationExecutor"), IResettableExecutor
{
private readonly List<ChatMessage> _messages = [];
@@ -90,5 +97,11 @@ internal static partial class WorkflowHelper
await context.YieldOutputAsync(formattedMessages, cancellationToken);
}
}
public ValueTask ResetAsync()
{
this._messages.Clear();
return default;
}
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,12 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>",
"REDIS_CONNECTION_STRING": "localhost:6379",
"REDIS_STREAM_TTL_MINUTES": "10"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,8 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -13,7 +13,7 @@
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="ModelContextProtocol" VersionOverride="1.2.0" />
<PackageReference Include="ModelContextProtocol" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
@@ -0,0 +1,6 @@
AZURE_AI_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-5
FOUNDRY_TOOLBOX_NAME=<your-toolbox-name>
AZURE_BEARER_TOKEN=DefaultAzureCredential
@@ -0,0 +1,26 @@
# Dockerfile for end-users consuming the Agent Framework via NuGet packages.
#
# This Dockerfile performs a full `dotnet restore` and `dotnet publish` inside the container,
# which only succeeds when the project references its dependencies via PackageReference (see the
# commented-out section in HostedToolboxMcpSkills.csproj). Contributors building from the
# agent-framework repository source must use Dockerfile.contributor instead because
# ProjectReference dependencies live outside this folder and cannot be restored from inside
# this build context.
#
# Use the official .NET 10.0 ASP.NET runtime as a parent image
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -o /app/publish
# Final stage
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENTRYPOINT ["dotnet", "HostedToolboxMcpSkills.dll"]
@@ -0,0 +1,18 @@
# Dockerfile for contributors building from the agent-framework repository source.
#
# This project uses ProjectReference to the local source, which means a standard
# multi-stage Docker build cannot resolve dependencies outside this folder.
# Pre-publish the app targeting the container runtime and copy the output:
#
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
# docker build -f Dockerfile.contributor -t hosted-toolbox-mcp-skills .
# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-toolbox-mcp-skills -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-toolbox-mcp-skills
#
# For end-users consuming the NuGet package (not ProjectReference), use the standard
# Dockerfile which performs a full dotnet restore + publish inside the container.
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
COPY out/ .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENTRYPOINT ["dotnet", "HostedToolboxMcpSkills.dll"]
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
<RootNamespace>HostedToolboxMcpSkills</RootNamespace>
<AssemblyName>HostedToolboxMcpSkills</AssemblyName>
<NoWarn>$(NoWarn);</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="ModelContextProtocol" VersionOverride="1.2.0" />
<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" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Mcp\Microsoft.Agents.AI.Mcp.csproj" />
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
</ItemGroup>
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
<PackageReference Include="Microsoft.Agents.AI.Mcp" Version="1.6.1-preview.260514.1" />
</ItemGroup>
-->
</Project>
@@ -0,0 +1,109 @@
// Copyright (c) Microsoft. All rights reserved.
// Hosted Toolbox MCP Skills Agent
//
// Demonstrates how to host an agent that discovers MCP-based skills from a
// Foundry Toolbox MCP endpoint and injects them as AIContextProviders using
// AgentSkillsProviderBuilder.UseMcpSkills().
//
// Required environment variables:
// AZURE_AI_PROJECT_ENDPOINT - Azure AI Foundry project endpoint
// FOUNDRY_TOOLBOX_NAME - Name of the Foundry Toolbox to connect to
// AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-5)
using System.Net.Http.Headers;
using Azure.AI.Projects;
using Azure.Core;
using Azure.Identity;
using DotNetEnv;
using Hosted_Shared_Contributor_Setup;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using ModelContextProtocol.Client;
// Load .env file if present (for local development)
Env.TraversePath().Load();
var projectEndpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5";
var toolboxName = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_NAME")
?? throw new InvalidOperationException("FOUNDRY_TOOLBOX_NAME is not set.");
// Build the Toolbox MCP URL from the project endpoint and toolbox name.
var toolboxMcpServerUrl = $"{projectEndpoint.TrimEnd('/')}/toolboxes/{toolboxName}/mcp?api-version=v1";
// 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());
// ── Connect to the Foundry Toolbox MCP endpoint ─────────────────────────────
// Create an HttpClient that attaches a fresh Foundry bearer token to every request.
using var httpClient = new HttpClient(new BearerTokenHandler(credential, "https://ai.azure.com/.default") { CheckCertificateRevocationList = true });
Console.WriteLine($"Connecting to Foundry Toolbox '{toolboxName}' MCP server...");
await using var mcpClient = await McpClient.CreateAsync(
new HttpClientTransport(
new HttpClientTransportOptions
{
Endpoint = new Uri(toolboxMcpServerUrl),
Name = toolboxName,
TransportMode = HttpTransportMode.StreamableHttp,
AdditionalHeaders = new Dictionary<string, string>
{
["Foundry-Features"] = "Toolboxes=V1Preview",
},
},
httpClient));
// ── Configure MCP-based skills provider ──────────────────────────────────────
var skillsProvider = new AgentSkillsProviderBuilder()
.UseMcpSkills(mcpClient)
.Build();
// ── Create the agent ─────────────────────────────────────────────────────────
AIAgent agent = new AIProjectClient(new Uri(projectEndpoint), credential)
.AsAIAgent(new ChatClientAgentOptions
{
Name = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-toolbox-mcp-skills",
Description = "Hosted agent with MCP skills discovered from a Foundry Toolbox",
ChatOptions = new()
{
ModelId = deployment,
Instructions = "You are a helpful assistant.",
},
AIContextProviders = [skillsProvider],
});
// ── Build the host ───────────────────────────────────────────────────────────
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
var app = builder.Build();
app.MapFoundryResponses();
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
app.MapDevTemporaryLocalAgentEndpoint();
app.Run();
// ---------------------------------------------------------------------------
// HttpClientHandler: attaches a fresh Foundry bearer token to every request
// ---------------------------------------------------------------------------
internal sealed class BearerTokenHandler(TokenCredential credential, string scope) : HttpClientHandler
{
private readonly TokenRequestContext _tokenContext = new([scope]);
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
AccessToken token = await credential.GetTokenAsync(this._tokenContext, cancellationToken).ConfigureAwait(false);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,103 @@
# Hosted-ToolboxMcpSkills
A hosted agent that discovers **MCP-based skills from a Foundry Toolbox** and makes them available to the agent using `AgentSkillsProviderBuilder.UseMcpSkills(mcpClient)`.
The `AgentSkillsProvider` is attached to the agent as a context provider and implements the [Agent Skills](https://agentskills.io/) progressive-disclosure pattern. When the agent is prompted, it discovers available skills in the Foundry Toolbox via the provider:
1. **Advertise** - skill names and descriptions are injected into the system prompt so the agent knows what is available.
2. **Load** - when the agent decides a skill is relevant, it retrieves the full skill body with detailed instructions via the provider.
3. **Read resources** - if a skill includes supplementary content (reference documents, assets), the agent reads them on demand via the provider.
This way the full skill body and resources are only loaded when the agent actually needs them, reducing token usage.
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- An Azure AI Foundry project with a deployed model (e.g., `gpt-5`)
- A Foundry Toolbox already configured with skills provisioned
- Azure CLI logged in (`az login`)
## Configuration
Copy the template and fill in your values:
```bash
cp .env.example .env
```
Edit `.env` and set your Azure AI Foundry project endpoint and toolbox name:
```env
AZURE_AI_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-5
FOUNDRY_TOOLBOX_NAME=my-toolbox
```
> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference.
## Running directly (contributors)
This project uses `ProjectReference` to build against the local Agent Framework source.
```bash
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills
dotnet run
```
The agent will start on `http://localhost:8088`.
### Test it
Using the Azure Developer CLI:
```bash
azd ai agent invoke --local "What skills do you have available?"
```
## Running with Docker
Since this project uses `ProjectReference`, use `Dockerfile.contributor` which takes a pre-published output.
### 1. Publish for the container runtime (Linux Alpine)
```bash
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
```
### 2. Build the Docker image
```bash
docker build -f Dockerfile.contributor -t hosted-toolbox-mcp-skills .
```
### 3. Run the container
Generate a bearer token on your host and pass it to the container:
```bash
# Generate token (expires in ~1 hour)
export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
# Run with token
docker run --rm -p 8088:8088 \
-e AGENT_NAME=hosted-toolbox-mcp-skills \
-e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
--env-file .env \
hosted-toolbox-mcp-skills
```
> **Note:** `AGENT_NAME` is passed via `-e` to simulate the platform injection. `AZURE_BEARER_TOKEN` provides Azure credentials to the container (tokens expire after ~1 hour). The `.env` file provides the remaining configuration.
### 4. Test it
Using the Azure Developer CLI:
```bash
azd ai agent invoke --local "What skills do you have available?"
```
## NuGet package users
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedToolboxMcpSkills.csproj` for the `PackageReference` alternative.
@@ -0,0 +1,43 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
name: hosted-toolbox-mcp-skills
displayName: "Hosted Toolbox MCP Skills Agent"
description: >
A hosted agent that discovers MCP-based skills from a Foundry Toolbox
and makes them available to the agent via the agent skills provider.
metadata:
tags:
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- Agent Framework
- MCP
- Model Context Protocol
- Agent Skills
- Foundry Toolbox
- Foundry Toolbox Skills
template:
name: hosted-toolbox-mcp-skills
kind: hosted
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
environment_variables:
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
- name: FOUNDRY_TOOLBOX_NAME
value: "{{FOUNDRY_TOOLBOX_NAME}}"
parameters:
properties:
- name: FOUNDRY_TOOLBOX_NAME
secret: false
description: Name of the Foundry Toolbox to connect to for MCP skill discovery
resources:
- kind: model
id: gpt-5
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
@@ -0,0 +1,14 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
kind: hosted
name: hosted-toolbox-mcp-skills
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
environment_variables:
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
- name: FOUNDRY_TOOLBOX_NAME
value: ${FOUNDRY_TOOLBOX_NAME}
@@ -13,8 +13,10 @@
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Core" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
<PackageReference Include="System.ClientModel" />
</ItemGroup>
<ItemGroup>
@@ -13,8 +13,10 @@
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Core" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
<PackageReference Include="System.ClientModel" />
</ItemGroup>
<ItemGroup>
@@ -101,6 +101,10 @@ else
throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided");
}
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
// if using Claims-based Identity for Authentication/Authorization
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
builder.AddA2AServer(hostA2AAgent);
var app = builder.Build();
@@ -15,6 +15,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
@@ -19,6 +19,11 @@ builder.Services.AddHttpClient().AddLogging();
builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(AGUIDojoServerSerializerContext.Default));
builder.Services.AddAGUI();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
app.UseHttpLogging();
@@ -49,6 +49,11 @@ var agent = new AzureOpenAIClient(
AGUIServerSerializerContext.Default.Options)
]);
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// Register the agent with the host and configure it to use an in-memory session store
// so that conversation state is maintained across requests. In production, you may want to use a persistent session store.
builder
@@ -14,6 +14,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AGUI\Microsoft.Agents.AI.AGUI.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
@@ -12,6 +12,11 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient().AddLogging();
builder.Services.AddAGUI();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
@@ -28,6 +28,10 @@ builder.AddDevUI();
builder.AddOpenAIChatCompletions();
builder.AddOpenAIResponses();
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
// if using Claims-based Identity for Authentication/Authorization
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
var pirateAgentBuilder = builder.AddAIAgent(
"pirate",
instructions: "You are a pirate. Speak like a pirate",
@@ -148,6 +152,10 @@ builder.Services.AddKeyedSingleton<AIAgent>("my-di-matchingname-agent", (sp, nam
pirateAgentBuilder.AddA2AServer();
knightsKnavesAgentBuilder.AddA2AServer();
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
// if using Claims-based Identity for Authentication/Authorization
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
var app = builder.Build();
app.MapOpenApi();