From d1d95d0d75864c9a0f44896d05aa7fffbbfe34cd Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Wed, 13 May 2026 13:47:37 -0700 Subject: [PATCH 1/2] Add sample for invoking Foundry Toolbox tools from declarative workflows --- dotnet/agent-framework-dotnet.slnx | 1 + dotnet/eng/verify-samples/WorkflowSamples.cs | 11 + .../InvokeFoundryToolboxMcp.csproj | 42 ++++ .../InvokeFoundryToolboxMcp.yaml | 87 +++++++ .../InvokeFoundryToolboxMcp/Program.cs | 212 ++++++++++++++++++ .../DefaultMcpToolHandler.cs | 98 +++++++- .../DefaultMcpToolHandlerTests.cs | 157 +++++++++++++ .../ObjectModel/InvokeMcpToolExecutorTest.cs | 38 ++++ 8 files changed, 645 insertions(+), 1 deletion(-) create mode 100644 dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.csproj create mode 100644 dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.yaml create mode 100644 dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 195521b1eb2..93cd6ade5ad 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -239,6 +239,7 @@ + diff --git a/dotnet/eng/verify-samples/WorkflowSamples.cs b/dotnet/eng/verify-samples/WorkflowSamples.cs index 2842f4af894..2793dd04c53 100644 --- a/dotnet/eng/verify-samples/WorkflowSamples.cs +++ b/dotnet/eng/verify-samples/WorkflowSamples.cs @@ -478,6 +478,17 @@ internal static class WorkflowSamples ExpectedOutputDescription = ["The output should show a workflow invoking a function tool (e.g. a menu plugin) to answer a question about the soup of the day."], }, + new SampleDefinition + { + Name = "Workflow_Declarative_InvokeFoundryToolboxMcp", + ProjectPath = "samples/03-workflows/Declarative/InvokeFoundryToolboxMcp", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME", "FOUNDRY_TOOLBOX_NAME", "FOUNDRY_AGENT_TOOLSET_API_VERSION"], + Inputs = ["How do I use Azure OpenAI with my data?"], + InputDelayMs = 3000, + ExpectedOutputDescription = ["The output should show a workflow using Foundry Toolbox MCP tools to search Microsoft Learn documentation and web search to provide a summary of results."], + }, + new SampleDefinition { Name = "Workflow_Declarative_InvokeMcpTool", diff --git a/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.csproj b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.csproj new file mode 100644 index 00000000000..3e70c3f9944 --- /dev/null +++ b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.csproj @@ -0,0 +1,42 @@ + + + + Exe + net10.0 + enable + enable + + + + true + true + true + true + + + + + + + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.yaml b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.yaml new file mode 100644 index 00000000000..d7941a51acc --- /dev/null +++ b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.yaml @@ -0,0 +1,87 @@ +# +# This workflow demonstrates invoking MCP tools through a Foundry toolbox MCP proxy. +# +# The toolbox is provisioned with TWO different tool types: +# 1. A Foundry built-in web_search tool +# 2. A Microsoft Learn MCP server (microsoft_docs) +# Both are surfaced through the same MCP-compatible toolbox endpoint. +# +# The workflow: +# 1. Accepts a documentation/web search query as input +# 2. Lists the tools exposed by the Foundry toolbox using reserved toolName: tools/list +# 3. Invokes the microsoft_docs_search MCP tool +# 4. Invokes the built-in web_search tool against the same toolbox endpoint +# 5. Uses an agent to summarize and combine both result sets +# +# Example input: +# How do I use Azure OpenAI with my data? +# +kind: Workflow +trigger: + + kind: OnConversationStart + id: workflow_invoke_foundry_toolbox_mcp + actions: + + # Set the search query from user input. + - kind: SetVariable + id: set_search_query + variable: Local.SearchQuery + value: =System.LastMessage.Text + + # List tools exposed by the Foundry toolbox MCP proxy. + - kind: InvokeMcpTool + id: list_toolbox_tools + serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL + serverLabel: foundry_toolbox + toolName: tools/list + conversationId: =System.ConversationId + headers: + Foundry-Features: Toolboxes=V1Preview + output: + autoSend: true + result: Local.ToolboxTools + + # Invoke a specific tool exposed through the toolbox and add the result to the conversation. + - kind: InvokeMcpTool + id: search_docs_with_toolbox + serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL + serverLabel: foundry_toolbox + toolName: =Env.FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL & "___microsoft_docs_search" + conversationId: =System.ConversationId + headers: + Foundry-Features: Toolboxes=V1Preview + arguments: + query: =Local.SearchQuery + output: + autoSend: true + result: Local.SearchResult + + # Invoke the web_search built-in tool through the same toolbox proxy. The toolbox surfaces + # built-in Foundry tools (like web_search) alongside MCP tools through one MCP-compatible + # endpoint. Note that web_search expects argument 'search_query' (not 'query'). + - kind: InvokeMcpTool + id: search_web_with_toolbox + serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL + serverLabel: foundry_toolbox + toolName: =Env.FOUNDRY_TOOLBOX_WEB_SEARCH_TOOL_NAME + conversationId: =System.ConversationId + headers: + Foundry-Features: Toolboxes=V1Preview + arguments: + search_query: =Local.SearchQuery + output: + autoSend: true + result: Local.WebSearchResult + + # Use the agent to summarize what happened and answer from the toolbox result. + - kind: InvokeAzureAgent + id: summarize_toolbox_result + agent: + name: FoundryToolboxMcpAgent + conversationId: =System.ConversationId + input: + messages: =UserMessage("Combine the Microsoft Learn docs results and the Foundry web search results in the conversation to answer the query " & Local.SearchQuery) + output: + autoSend: true + messages: Local.Summary diff --git a/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs new file mode 100644 index 00000000000..5f577052b61 --- /dev/null +++ b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs @@ -0,0 +1,212 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates using InvokeMcpTool to call MCP tools through a Foundry toolbox. +// It creates a sample toolbox that exposes Microsoft Learn MCP tools, lists the toolbox tools +// through the reserved tools/list operation, then calls microsoft_docs_search from the workflow. + +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Net.Http.Headers; +using Azure.AI.Projects; +using Azure.AI.Projects.Agents; +using Azure.Core; +using Azure.Identity; +using Microsoft.Agents.AI.Workflows.Declarative.Mcp; +using Microsoft.Extensions.Configuration; +using OpenAI.Responses; +using Shared.Foundry; +using Shared.Workflows; + +#pragma warning disable OPENAI001 // Experimental API +#pragma warning disable AAIP001 // AgentToolboxes is experimental + +namespace Demo.Workflows.Declarative.InvokeFoundryToolboxMcp; + +/// +/// Demonstrates a workflow that uses InvokeMcpTool to call MCP tools exposed through a Foundry toolbox. +/// +/// +/// This sample provisions a toolbox with Microsoft Learn MCP tools, uses the reserved +/// tools/list tool name to list the toolbox tools, calls one specific toolbox tool, +/// and has a Foundry agent summarize the results. +/// +internal sealed class Program +{ + private const string ToolboxNameSetting = "FOUNDRY_TOOLBOX_NAME"; + private const string ToolboxApiVersionSetting = "FOUNDRY_AGENT_TOOLSET_API_VERSION"; + private const string ToolboxMcpServerUrlSetting = "FOUNDRY_TOOLBOX_MCP_SERVER_URL"; + private const string DocsServerLabelSetting = "FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL"; + private const string WebSearchToolNameSetting = "FOUNDRY_TOOLBOX_WEB_SEARCH_TOOL_NAME"; + private const string DefaultToolboxName = "declarative_foundry_toolbox_mcp"; + private const string DefaultToolboxApiVersion = "v1"; + private const string DefaultDocsServerLabel = "microsoft_docs"; + private const string DefaultWebSearchToolName = "web_search"; + + public static async Task Main(string[] args) + { + // Initialize configuration + IConfiguration configuration = Application.InitializeConfig(); + Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint)); + string toolboxName = configuration[ToolboxNameSetting] ?? DefaultToolboxName; + string toolboxApiVersion = configuration[ToolboxApiVersionSetting] ?? DefaultToolboxApiVersion; + string docsServerLabel = configuration[DocsServerLabelSetting] ?? DefaultDocsServerLabel; + string webSearchToolName = configuration[WebSearchToolNameSetting] ?? DefaultWebSearchToolName; + + // 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. + DefaultAzureCredential credential = new(); + + // Ensure sample toolbox and agent exist in Foundry + string toolboxEndpoint = await CreateSampleToolboxAsync(toolboxName, docsServerLabel, foundryEndpoint, credential); + string toolboxMcpServerUrl = BuildToolboxMcpServerUrl(toolboxEndpoint, toolboxName, toolboxApiVersion); + Environment.SetEnvironmentVariable(ToolboxMcpServerUrlSetting, toolboxMcpServerUrl); + // Expose the server label to the workflow so the YAML can build prefixed MCP tool names dynamically. + Environment.SetEnvironmentVariable(DocsServerLabelSetting, docsServerLabel); + // Expose the web search tool name so the YAML can reference it without hard-coding. + Environment.SetEnvironmentVariable(WebSearchToolNameSetting, webSearchToolName); + + await CreateAgentAsync(foundryEndpoint, configuration, credential); + + // Get input from command line or console + string workflowInput = Application.GetInput(args); + + // Create the MCP tool handler for invoking the Foundry toolbox MCP proxy. + List createdHttpClients = []; + DefaultMcpToolHandler mcpToolHandler = new( + httpClientProvider: async (serverUrl, _) => + { + await Task.CompletedTask.ConfigureAwait(false); + + if (!string.Equals(serverUrl, toolboxMcpServerUrl, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + FoundryToolboxBearerTokenHandler handler = new(credential) + { + InnerHandler = new HttpClientHandler() + }; + HttpClient httpClient = new(handler); + createdHttpClients.Add(httpClient); + return httpClient; + }); + + try + { + // Create the workflow factory with MCP tool provider + WorkflowFactory workflowFactory = new("InvokeFoundryToolboxMcp.yaml", foundryEndpoint) + { + McpToolHandler = mcpToolHandler + }; + + // Execute the workflow + WorkflowRunner runner = new() { UseJsonCheckpoints = true }; + await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput); + } + finally + { + // Clean up connections and dispose created HttpClients + await mcpToolHandler.DisposeAsync(); + + foreach (HttpClient httpClient in createdHttpClients) + { + httpClient.Dispose(); + } + } + } + + private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration, TokenCredential credential) + { + AIProjectClient aiProjectClient = new(foundryEndpoint, credential); + + await aiProjectClient.CreateAgentAsync( + agentName: "FoundryToolboxMcpAgent", + agentDefinition: DefineToolboxAgent(configuration), + agentDescription: "Summarizes Foundry toolbox MCP tool results"); + } + + private static DeclarativeAgentDefinition DefineToolboxAgent(IConfiguration configuration) + { + return new DeclarativeAgentDefinition(configuration.GetValue(Application.Settings.FoundryModel)) + { + Instructions = + """ + You are a helpful assistant that explains results produced by tools exposed through a Foundry toolbox. + The conversation history contains output from BOTH a Microsoft Learn documentation search (MCP) and a Foundry web search. + Synthesize an answer that draws on both sources, calls out where they agree or differ, and notes which toolbox tool produced each fact when it is relevant. + Be concise. + """ + }; + } + + private static async Task CreateSampleToolboxAsync(string name, string serverLabel, Uri foundryEndpoint, TokenCredential credential) + { + AgentAdministrationClientOptions options = new(); + options.AddPolicy(new FoundryFeaturesPolicy("Toolboxes=V1Preview"), PipelinePosition.PerCall); + AgentAdministrationClient adminClient = new(foundryEndpoint, credential, options); + AgentToolboxes toolboxClient = adminClient.GetAgentToolboxes(); + + try + { + await toolboxClient.DeleteToolboxAsync(name); + Console.WriteLine($"Deleted existing toolbox '{name}'"); + } + catch (ClientResultException ex) when (ex.Status == 404) + { + // Toolbox does not exist. + } + + ProjectsAgentTool webTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateWebSearchTool()); + + ProjectsAgentTool mcpTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateMcpTool( + serverLabel: serverLabel, + serverUri: new Uri("https://learn.microsoft.com/api/mcp"), + toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval))); + + ToolboxVersion created = (await toolboxClient.CreateToolboxVersionAsync( + name: name, + tools: [webTool, mcpTool], + description: "Sample toolbox combining Foundry web search with the Microsoft Learn MCP tools for the declarative InvokeFoundryToolboxMcp sample.")).Value; + + Console.WriteLine($"Created toolbox '{created.Name}' v{created.Version} ({created.Tools.Count} tool(s))"); + + return $"{foundryEndpoint.ToString().TrimEnd('/')}/toolboxes"; + } + + private static string BuildToolboxMcpServerUrl(string toolboxEndpoint, string toolboxName, string apiVersion) => + $"{toolboxEndpoint.TrimEnd('/')}/{toolboxName}/mcp?api-version={Uri.EscapeDataString(apiVersion)}"; + + private sealed class FoundryToolboxBearerTokenHandler(TokenCredential credential) : DelegatingHandler + { + private static readonly TokenRequestContext s_tokenContext = + new(["https://ai.azure.com/.default"]); + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + AccessToken token = await credential.GetTokenAsync(s_tokenContext, cancellationToken); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); + + return await base.SendAsync(request, cancellationToken); + } + } + + private sealed class FoundryFeaturesPolicy(string feature) : PipelinePolicy + { + private const string FeatureHeader = "Foundry-Features"; + + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + message.Request.Headers.Add(FeatureHeader, feature); + ProcessNext(message, pipeline, currentIndex); + } + + public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + message.Request.Headers.Add(FeatureHeader, feature); + return ProcessNextAsync(message, pipeline, currentIndex); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs index 681cd5dc85f..34d914589ef 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs @@ -3,12 +3,15 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.IO; using System.Linq; using System.Net.Http; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -24,6 +27,14 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Mcp; /// public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable { + /// + /// Reserved toolName value that maps an request + /// to the MCP protocol tools/list discovery operation. + /// + public const string ListToolsToolName = "tools/list"; + + private static readonly JsonWriterOptions s_toolListJsonWriterOptions = new() { Indented = true }; + private readonly Func>? _httpClientProvider; private readonly Dictionary _clients = []; private readonly Dictionary _ownedHttpClients = []; @@ -53,9 +64,21 @@ public async Task InvokeToolAsync( CancellationToken cancellationToken = default) { // TODO: Handle connectionName and server label appropriately when Hosted scenario supports them. For now, ignore - McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString()); + if (IsListToolsToolName(toolName)) + { + ThrowIfListToolsArgumentsSpecified(arguments); + } + McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false); + if (IsListToolsToolName(toolName)) + { + IList tools = await client.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + return CreateListToolsResultContent(tools.Select(tool => tool.ProtocolTool)); + } + + McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString()); + // Convert IDictionary to IReadOnlyDictionary for CallToolAsync IReadOnlyDictionary? readOnlyArguments = arguments is null ? null @@ -72,6 +95,23 @@ public async Task InvokeToolAsync( return resultContent; } + internal static bool IsListToolsToolName(string toolName) => + string.Equals(toolName, ListToolsToolName, StringComparison.Ordinal); + + internal static McpServerToolResultContent CreateListToolsResultContent(IEnumerable tools) + { + Throw.IfNull(tools); + + McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString()) + { + Outputs = [] + }; + + resultContent.Outputs.Add(new TextContent(SerializeToolsList(tools))); + + return resultContent; + } + /// public async ValueTask DisposeAsync() { @@ -183,6 +223,16 @@ private static string ComputeHeadersHash(IDictionary? headers) return hashCode.ToString(CultureInfo.InvariantCulture); } + private static void ThrowIfListToolsArgumentsSpecified(IDictionary? arguments) + { + if (arguments is { Count: > 0 }) + { + throw new ArgumentException( + $"The reserved MCP '{ListToolsToolName}' operation does not accept tool arguments.", + nameof(arguments)); + } + } + private static void PopulateResultContent(McpServerToolResultContent resultContent, CallToolResult result) { // Ensure Outputs list is initialized @@ -230,6 +280,17 @@ internal static AIContent ConvertContentBlock(ContentBlock block) TextContentBlock text => new TextContent(text.Text), ImageContentBlock image => CreateDataContent(image.Data, image.MimeType ?? "image/*"), AudioContentBlock audio => CreateDataContent(audio.Data, audio.MimeType ?? "audio/*"), + EmbeddedResourceBlock embedded => ConvertEmbeddedResource(embedded), + _ => new TextContent(block.ToString() ?? string.Empty), + }; + } + + private static AIContent ConvertEmbeddedResource(EmbeddedResourceBlock block) + { + return block.Resource switch + { + TextResourceContents text => new TextContent(text.Text), + BlobResourceContents blob => CreateDataContent(blob.Blob, blob.MimeType ?? "application/octet-stream"), _ => new TextContent(block.ToString() ?? string.Empty), }; } @@ -255,4 +316,39 @@ private static DataContent CreateDataContent(ReadOnlyMemory base64Utf8Data return new DataContent($"data:{mediaType};base64,{base64}", mediaType); } + + private static string SerializeToolsList(IEnumerable tools) + { + using MemoryStream stream = new(); + using (Utf8JsonWriter writer = new(stream, s_toolListJsonWriterOptions)) + { + writer.WriteStartObject(); + writer.WriteStartArray("tools"); + + foreach (Tool tool in tools) + { + writer.WriteStartObject(); + writer.WriteString("name", tool.Name); + writer.WriteString("description", tool.Description); + writer.WritePropertyName("inputSchema"); + tool.InputSchema.WriteTo(writer); + writer.WritePropertyName("outputSchema"); + if (tool.OutputSchema is JsonElement outputSchema) + { + outputSchema.WriteTo(writer); + } + else + { + writer.WriteNullValue(); + } + + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + } + + return Encoding.UTF8.GetString(stream.ToArray()); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs index abfa95cc361..f9cb5cdb56a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Net.Http; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using FluentAssertions; @@ -320,6 +321,92 @@ await handler.InvokeToolAsync( #endregion + #region Reserved Tools/List Tests + + [Fact] + public void IsListToolsToolName_WithReservedName_ShouldReturnTrue() + { + // Act + bool result = DefaultMcpToolHandler.IsListToolsToolName(DefaultMcpToolHandler.ListToolsToolName); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void IsListToolsToolName_WithRegularToolName_ShouldReturnFalse() + { + // Act + bool result = DefaultMcpToolHandler.IsListToolsToolName("search"); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public async Task InvokeToolAsync_WithListToolsArguments_ShouldThrowArgumentExceptionAsync() + { + // Arrange + DefaultMcpToolHandler handler = new(); + + try + { + // Act + Func act = async () => await handler.InvokeToolAsync( + serverUrl: "http://localhost:12345/mcp", + serverLabel: "test", + toolName: DefaultMcpToolHandler.ListToolsToolName, + arguments: new Dictionary { ["ignored"] = true }, + headers: null, + connectionName: null); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*does not accept tool arguments*"); + } + finally + { + await handler.DisposeAsync(); + } + } + + [Fact] + public async Task CreateListToolsResultContent_WithTools_ShouldSerializeToolMetadataAsync() + { + // Arrange + JsonElement inputSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "query": { + "type": "string" + } + }, + "required": [ "query" ] + } + """); + Tool tool = new() + { + Name = "search", + Description = "Searches documentation.", + InputSchema = inputSchema + }; + + // Act + McpServerToolResultContent result = DefaultMcpToolHandler.CreateListToolsResultContent([tool]); + + // Assert + TextContent text = result.Outputs.Should().ContainSingle().Subject.Should().BeOfType().Subject; + using JsonDocument document = JsonDocument.Parse(text.Text); + JsonElement listedTool = document.RootElement.GetProperty("tools")[0]; + listedTool.GetProperty("name").GetString().Should().Be("search"); + listedTool.GetProperty("description").GetString().Should().Be("Searches documentation."); + listedTool.GetProperty("inputSchema").GetProperty("properties").GetProperty("query").GetProperty("type").GetString().Should().Be("string"); + } + + #endregion + #region Interface Implementation Tests [Fact] @@ -488,5 +575,75 @@ public void ConvertContentBlock_AudioContentBlock_WithNullMimeType_ShouldDefault dataContent.MediaType.Should().Be("audio/*"); } + [Fact] + public void ConvertContentBlock_EmbeddedResourceBlock_WithTextResource_ShouldReturnTextContent() + { + // Arrange + EmbeddedResourceBlock block = new() + { + Resource = new TextResourceContents + { + Text = "embedded text payload", + Uri = "resource://example", + MimeType = "text/plain", + }, + }; + + // Act + AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); + + // Assert + result.Should().BeOfType() + .Which.Text.Should().Be("embedded text payload"); + } + + [Fact] + public void ConvertContentBlock_EmbeddedResourceBlock_WithBlobResource_ShouldReturnDataContent() + { + // Arrange + byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA"); + EmbeddedResourceBlock block = new() + { + Resource = new BlobResourceContents + { + Blob = new ReadOnlyMemory(base64Bytes), + Uri = "resource://example.bin", + MimeType = "application/zip", + }, + }; + + // Act + AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); + + // Assert + DataContent dataContent = result.Should().BeOfType().Subject; + dataContent.MediaType.Should().Be("application/zip"); + dataContent.Uri.Should().Be("data:application/zip;base64,UklGRiQA"); + } + + [Fact] + public void ConvertContentBlock_EmbeddedResourceBlock_WithBlobResource_NullMimeType_DefaultsToOctetStream() + { + // Arrange + byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA"); + EmbeddedResourceBlock block = new() + { + Resource = new BlobResourceContents + { + Blob = new ReadOnlyMemory(base64Bytes), + Uri = "resource://example.bin", + MimeType = null!, + }, + }; + + // Act + AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); + + // Assert + DataContent dataContent = result.Should().BeOfType().Subject; + dataContent.MediaType.Should().Be("application/octet-stream"); + dataContent.Uri.Should().Be("data:application/octet-stream;base64,UklGRiQA"); + } + #endregion } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs index a1337b3e2dc..d047badaf75 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs @@ -432,6 +432,44 @@ public async Task InvokeMcpToolExecuteWithNullOutputAsync() VerifyInvocationEvent(events); } + [Fact] + public async Task InvokeMcpToolExecuteWithReservedListToolsNameAsync() + { + // Arrange + this.State.InitializeSystem(); + const string ListToolsToolName = "tools/list"; + string? capturedToolName = null; + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolExecuteWithReservedListToolsNameAsync), + serverUrl: TestServerUrl, + toolName: ListToolsToolName); + Mock mockProvider = new(); + mockProvider.Setup(provider => provider.InvokeToolAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny(), + It.IsAny())) + .Callback?, IDictionary?, string?, CancellationToken>( + (_, _, toolName, _, _, _, _) => capturedToolName = toolName) + .ReturnsAsync(new McpServerToolResultContent("list-tools-call-id") + { + Outputs = [new TextContent("{\"tools\":[]}")] + }); + MockAgentProvider mockAgentProvider = new(); + InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State); + + // Act + WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false); + + // Assert + VerifyModel(model, action); + VerifyInvocationEvent(events); + Assert.Equal(ListToolsToolName, capturedToolName); + } + [Fact] public async Task InvokeMcpToolExecuteWithMultipleContentTypesAsync() { From 3f971550bda0ef7e322b32d5817578fc0d35e8cd Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Wed, 13 May 2026 15:34:27 -0700 Subject: [PATCH 2/2] Addressed initial PR comments. --- .../InvokeFoundryToolboxMcp.yaml | 6 +++--- .../InvokeFoundryToolboxMcp/Program.cs | 18 ++++++++++++------ .../DefaultMcpToolHandler.cs | 11 ++++------- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.yaml b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.yaml index d7941a51acc..b5f6f39316e 100644 --- a/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.yaml +++ b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.yaml @@ -37,7 +37,7 @@ trigger: toolName: tools/list conversationId: =System.ConversationId headers: - Foundry-Features: Toolboxes=V1Preview + Foundry-Features: Toolboxes=V1Preview output: autoSend: true result: Local.ToolboxTools @@ -50,7 +50,7 @@ trigger: toolName: =Env.FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL & "___microsoft_docs_search" conversationId: =System.ConversationId headers: - Foundry-Features: Toolboxes=V1Preview + Foundry-Features: Toolboxes=V1Preview arguments: query: =Local.SearchQuery output: @@ -67,7 +67,7 @@ trigger: toolName: =Env.FOUNDRY_TOOLBOX_WEB_SEARCH_TOOL_NAME conversationId: =System.ConversationId headers: - Foundry-Features: Toolboxes=V1Preview + Foundry-Features: Toolboxes=V1Preview arguments: search_query: =Local.SearchQuery output: diff --git a/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs index 5f577052b61..6636cb13a7f 100644 --- a/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs @@ -6,6 +6,7 @@ using System.ClientModel; using System.ClientModel.Primitives; +using System.Collections.Concurrent; using System.Net.Http.Headers; using Azure.AI.Projects; using Azure.AI.Projects.Agents; @@ -60,11 +61,15 @@ public static async Task Main(string[] args) // Ensure sample toolbox and agent exist in Foundry string toolboxEndpoint = await CreateSampleToolboxAsync(toolboxName, docsServerLabel, foundryEndpoint, credential); string toolboxMcpServerUrl = BuildToolboxMcpServerUrl(toolboxEndpoint, toolboxName, toolboxApiVersion); - Environment.SetEnvironmentVariable(ToolboxMcpServerUrlSetting, toolboxMcpServerUrl); - // Expose the server label to the workflow so the YAML can build prefixed MCP tool names dynamically. - Environment.SetEnvironmentVariable(DocsServerLabelSetting, docsServerLabel); - // Expose the web search tool name so the YAML can reference it without hard-coding. - Environment.SetEnvironmentVariable(WebSearchToolNameSetting, webSearchToolName); + IConfiguration workflowConfiguration = new ConfigurationBuilder() + .AddConfiguration(configuration) + .AddInMemoryCollection(new Dictionary + { + [ToolboxMcpServerUrlSetting] = toolboxMcpServerUrl, + [DocsServerLabelSetting] = docsServerLabel, + [WebSearchToolNameSetting] = webSearchToolName, + }) + .Build(); await CreateAgentAsync(foundryEndpoint, configuration, credential); @@ -72,7 +77,7 @@ public static async Task Main(string[] args) string workflowInput = Application.GetInput(args); // Create the MCP tool handler for invoking the Foundry toolbox MCP proxy. - List createdHttpClients = []; + ConcurrentBag createdHttpClients = []; DefaultMcpToolHandler mcpToolHandler = new( httpClientProvider: async (serverUrl, _) => { @@ -97,6 +102,7 @@ public static async Task Main(string[] args) // Create the workflow factory with MCP tool provider WorkflowFactory workflowFactory = new("InvokeFoundryToolboxMcp.yaml", foundryEndpoint) { + Configuration = workflowConfiguration, McpToolHandler = mcpToolHandler }; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs index 34d914589ef..66da428cf60 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs @@ -67,16 +67,13 @@ public async Task InvokeToolAsync( if (IsListToolsToolName(toolName)) { ThrowIfListToolsArgumentsSpecified(arguments); + McpClient listToolsClient = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false); + IList tools = await listToolsClient.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + return CreateListToolsResultContent(tools.Select(tool => tool.ProtocolTool)); } McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false); - if (IsListToolsToolName(toolName)) - { - IList tools = await client.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false); - return CreateListToolsResultContent(tools.Select(tool => tool.ProtocolTool)); - } - McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString()); // Convert IDictionary to IReadOnlyDictionary for CallToolAsync @@ -349,6 +346,6 @@ private static string SerializeToolsList(IEnumerable tools) writer.WriteEndObject(); } - return Encoding.UTF8.GetString(stream.ToArray()); + return Encoding.UTF8.GetString(stream.GetBuffer(), 0, (int)stream.Length); } }