.NET: Support InvokeMcpTool for declarative workflows (#4204)

* Initial implementation of InvokeMcpTool in declarative workflow

* Cleaned up sample implementation

* Updated sample comments.

* Added missing executor routing attribute

* Fix PR comments.

* Updated based on PR comments.

* Updated based on PR comments.

* Removed unnecessary using statement.
This commit is contained in:
Peter Ibekwe
2026-02-25 11:21:36 -08:00
committed by GitHub
Unverified
parent 2e26bb9387
commit de9d886aba
24 changed files with 2663 additions and 60 deletions
+3 -3
View File
@@ -111,9 +111,9 @@
<!-- Identity -->
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.78.0" />
<!-- Workflows -->
<PackageVersion Include="Microsoft.Agents.ObjectModel" Version="2026.2.3.1" />
<PackageVersion Include="Microsoft.Agents.ObjectModel.Json" Version="2026.2.3.1" />
<PackageVersion Include="Microsoft.Agents.ObjectModel.PowerFx" Version="2026.2.3.1" />
<PackageVersion Include="Microsoft.Agents.ObjectModel" Version="2026.2.4.1" />
<PackageVersion Include="Microsoft.Agents.ObjectModel.Json" Version="2026.2.4.1" />
<PackageVersion Include="Microsoft.Agents.ObjectModel.PowerFx" Version="2026.2.4.1" />
<PackageVersion Include="Microsoft.PowerFx.Interpreter" Version="1.8.1" />
<!-- Durable Task -->
<PackageVersion Include="Microsoft.DurableTask.Client" Version="1.18.0" />
+3
View File
@@ -228,6 +228,7 @@
<Project Path="samples/GettingStarted/Workflows/Declarative/StudentTeacher/StudentTeacher.csproj" />
<Project Path="samples/GettingStarted/Workflows/Declarative/ToolApproval/ToolApproval.csproj" />
<Project Path="samples/GettingStarted/Workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj" />
<Project Path="samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/Workflows/Declarative/Examples/">
<File Path="../workflow-samples/CustomerSupport.yaml" />
@@ -439,6 +440,7 @@
<Project Path="src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj" />
@@ -484,6 +486,7 @@
<Project Path="tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/Microsoft.Agents.AI.Workflows.Generators.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj" />
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectSharedFoundryAgents>true</InjectSharedFoundryAgents>
<InjectSharedWorkflowsExecution>true</InjectSharedWorkflowsExecution>
<InjectSharedWorkflowsSettings>true</InjectSharedWorkflowsSettings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative.AzureAI\Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative.Mcp\Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="InvokeMcpTool.yaml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,63 @@
#
# This workflow demonstrates invoking MCP tools directly from a declarative workflow.
# Uses the Foundry MCP server to search AI model details.
#
# The workflow:
# 1. Accepts a model search term as input
# 2. Invokes the Foundry MCP tool
# 3. Invokes the Microsoft Learn MCP tool
# 4. Uses an agent to summarize the results
#
# Example input:
# gpt-4.1
#
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_invoke_mcp_tool
actions:
# Set the search query from user input or use default
- kind: SetVariable
id: set_search_query
variable: Local.SearchQuery
value: =System.LastMessage.Text
# Invoke MCP search tool on Foundry MCP server
- kind: InvokeMcpTool
id: invoke_foundry_search
serverUrl: https://mcp.ai.azure.com
serverLabel: azure_mcp_server
toolName: model_details_get
conversationId: =System.ConversationId
arguments:
modelName: =Local.SearchQuery
output:
autoSend: true
result: Local.FoundrySearchResult
# Invoke MCP search tool on Microsoft Learn server
- kind: InvokeMcpTool
id: invoke_docs_search
serverUrl: https://learn.microsoft.com/api/mcp
serverLabel: microsoft_docs
toolName: microsoft_docs_search
conversationId: =System.ConversationId
arguments:
query: =Local.SearchQuery
output:
autoSend: true
result: Local.DocsSearchResult
# Use the search agent to provide a helpful response based on results
- kind: InvokeAzureAgent
id: summarize_results
agent:
name: McpSearchAgent
conversationId: =System.ConversationId
input:
messages: =UserMessage("Based on the search results for '" & Local.SearchQuery & "', please provide a helpful summary.")
output:
autoSend: true
result: Local.Summary
@@ -0,0 +1,141 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates using the InvokeMcpTool action to call MCP (Model Context Protocol)
// server tools directly from a declarative workflow. MCP servers expose tools that can be
// invoked to perform specific tasks, like searching documentation or executing operations.
using Azure.AI.Projects;
using Azure.AI.Projects.OpenAI;
using Azure.Core;
using Azure.Identity;
using Microsoft.Agents.AI.Workflows.Declarative.Mcp;
using Microsoft.Extensions.Configuration;
using Shared.Foundry;
using Shared.Workflows;
namespace Demo.Workflows.Declarative.InvokeMcpTool;
/// <summary>
/// Demonstrates a workflow that uses InvokeMcpTool to call MCP server tools
/// directly from the workflow.
/// </summary>
/// <remarks>
/// <para>
/// The InvokeMcpTool action allows workflows to invoke tools on MCP (Model Context Protocol)
/// servers. This enables:
/// </para>
/// <list type="bullet">
/// <item>Searching external data sources like documentation</item>
/// <item>Executing operations on remote servers</item>
/// <item>Integrating with MCP-compatible services</item>
/// </list>
/// <para>
/// This sample uses the Microsoft Learn MCP server to search Azure documentation and the Azure foundry MCP server to get AI model details.
/// When you run the sample, provide an AI model (e.g. gpt-4.1-mini) as input,
/// The workflow will use the MCP tools to find relevant information about the model from Microsoft Learn and foundry, then an agent will summarize the results.
/// </para>
/// <para>
/// See the README.md file in the parent folder (../README.md) for detailed
/// information about the configuration required to run this sample.
/// </para>
/// </remarks>
internal sealed class Program
{
public static async Task Main(string[] args)
{
// Initialize configuration
IConfiguration configuration = Application.InitializeConfig();
Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint));
// Ensure sample agent exists in Foundry
await CreateAgentAsync(foundryEndpoint, configuration);
// Get input from command line or console
string workflowInput = Application.GetInput(args);
// Create the MCP tool handler for invoking MCP server tools.
// The HttpClient callback allows configuring authentication per MCP server.
// Different MCP servers may require different authentication configurations.
// For Production scenarios, consider implementing a more robust HttpClient management strategy to reuse HttpClient instances and manage their lifetimes appropriately.
List<HttpClient> createdHttpClients = [];
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
DefaultAzureCredential credential = new();
DefaultMcpToolHandler mcpToolHandler = new(
httpClientProvider: async (serverUrl, cancellationToken) =>
{
if (serverUrl.StartsWith("https://mcp.ai.azure.com", StringComparison.OrdinalIgnoreCase))
{
// Acquire token for the Azure MCP server
AccessToken token = await credential.GetTokenAsync(
new TokenRequestContext(["https://mcp.ai.azure.com/.default"]),
cancellationToken);
// Create HttpClient with Authorization header
HttpClient httpClient = new();
httpClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token.Token);
createdHttpClients.Add(httpClient);
return httpClient;
}
if (serverUrl.StartsWith("https://learn.microsoft.com", StringComparison.OrdinalIgnoreCase))
{
// Microsoft Learn MCP server does not require authentication
HttpClient httpClient = new();
createdHttpClients.Add(httpClient);
return httpClient;
}
// Return null for unknown servers to use the default HttpClient without auth.
return null;
});
try
{
// Create the workflow factory with MCP tool provider
WorkflowFactory workflowFactory = new("InvokeMcpTool.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)
{
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
AIProjectClient aiProjectClient = new(foundryEndpoint, new DefaultAzureCredential());
await aiProjectClient.CreateAgentAsync(
agentName: "McpSearchAgent",
agentDefinition: DefineSearchAgent(configuration),
agentDescription: "Provides information based on search results");
}
private static PromptAgentDefinition DefineSearchAgent(IConfiguration configuration)
{
return new PromptAgentDefinition(configuration.GetValue(Application.Settings.FoundryModelMini))
{
Instructions =
"""
You are a helpful assistant that answers questions based on search results.
Use the information provided in the conversation history to answer questions.
If the information is already available in the conversation, use it directly.
Be concise and helpful in your responses.
"""
};
}
}
@@ -0,0 +1,252 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
namespace Microsoft.Agents.AI.Workflows.Declarative.Mcp;
/// <summary>
/// Default implementation of <see cref="IMcpToolHandler"/> using the MCP C# SDK.
/// </summary>
/// <remarks>
/// This provider supports per-server authentication via the <c>httpClientProvider</c> callback.
/// The callback allows different MCP servers to use different authentication configurations by returning
/// a pre-configured <see cref="HttpClient"/> for each server.
/// </remarks>
public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
{
private readonly Func<string, CancellationToken, Task<HttpClient?>>? _httpClientProvider;
private readonly Dictionary<string, McpClient> _clients = [];
private readonly Dictionary<string, HttpClient> _ownedHttpClients = [];
private readonly SemaphoreSlim _clientLock = new(1, 1);
/// <summary>
/// Initializes a new instance of the <see cref="DefaultMcpToolHandler"/> class.
/// </summary>
/// <param name="httpClientProvider">
/// An optional callback that provides an <see cref="HttpClient"/> for each MCP server.
/// The callback receives (serverUrl, cancellationToken) and should return an HttpClient
/// configured with any required authentication. Return <see langword="null"/> to use a default HttpClient with no auth.
/// </param>
public DefaultMcpToolHandler(Func<string, CancellationToken, Task<HttpClient?>>? httpClientProvider = null)
{
this._httpClientProvider = httpClientProvider;
}
/// <inheritdoc/>
public async Task<McpServerToolResultContent> InvokeToolAsync(
string serverUrl,
string? serverLabel,
string toolName,
IDictionary<string, object?>? arguments,
IDictionary<string, string>? headers,
string? connectionName,
CancellationToken cancellationToken = default)
{
// TODO: Handle connectionName and server label appropriately when Hosted scenario supports them. For now, ignore
McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString());
McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false);
// Convert IDictionary to IReadOnlyDictionary for CallToolAsync
IReadOnlyDictionary<string, object?>? readOnlyArguments = arguments is null
? null
: arguments as IReadOnlyDictionary<string, object?> ?? new Dictionary<string, object?>(arguments);
CallToolResult result = await client.CallToolAsync(
toolName,
readOnlyArguments,
cancellationToken: cancellationToken).ConfigureAwait(false);
// Map MCP content blocks to MEAI AIContent types
PopulateResultContent(resultContent, result);
return resultContent;
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
await this._clientLock.WaitAsync().ConfigureAwait(false);
try
{
foreach (McpClient client in this._clients.Values)
{
await client.DisposeAsync().ConfigureAwait(false);
}
this._clients.Clear();
// Dispose only HttpClients that the handler created (not user-provided ones)
foreach (HttpClient httpClient in this._ownedHttpClients.Values)
{
httpClient.Dispose();
}
this._ownedHttpClients.Clear();
}
finally
{
this._clientLock.Release();
}
this._clientLock.Dispose();
}
private async Task<McpClient> GetOrCreateClientAsync(
string serverUrl,
string? serverLabel,
IDictionary<string, string>? headers,
CancellationToken cancellationToken)
{
string normalizedUrl = serverUrl.Trim().ToUpperInvariant();
string clientCacheKey = $"{normalizedUrl}|{ComputeHeadersHash(headers)}";
await this._clientLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (this._clients.TryGetValue(clientCacheKey, out McpClient? existingClient))
{
return existingClient;
}
McpClient newClient = await this.CreateClientAsync(serverUrl, serverLabel, headers, normalizedUrl, cancellationToken).ConfigureAwait(false);
this._clients[clientCacheKey] = newClient;
return newClient;
}
finally
{
this._clientLock.Release();
}
}
private async Task<McpClient> CreateClientAsync(
string serverUrl,
string? serverLabel,
IDictionary<string, string>? headers,
string httpClientCacheKey,
CancellationToken cancellationToken)
{
// Get or create HttpClient (Can be shared across McpClients for the same server)
HttpClient? httpClient = null;
if (this._httpClientProvider is not null)
{
httpClient = await this._httpClientProvider(serverUrl, cancellationToken).ConfigureAwait(false);
}
if (httpClient is null && !this._ownedHttpClients.TryGetValue(httpClientCacheKey, out httpClient))
{
httpClient = new HttpClient();
this._ownedHttpClients[httpClientCacheKey] = httpClient;
}
HttpClientTransportOptions transportOptions = new()
{
Endpoint = new Uri(serverUrl),
Name = serverLabel ?? "McpClient",
AdditionalHeaders = headers,
TransportMode = HttpTransportMode.AutoDetect
};
HttpClientTransport transport = new(transportOptions, httpClient);
return await McpClient.CreateAsync(transport, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static string ComputeHeadersHash(IDictionary<string, string>? headers)
{
if (headers is null || headers.Count == 0)
{
return string.Empty;
}
// Build a deterministic, sorted representation of the headers
// Within a single process lifetime, the hashcodes are consistent.
// This will ensure that the same set of headers always produces the same hash, regardless of order.
SortedDictionary<string, string> sorted = new(headers.ToDictionary(h => h.Key.ToUpperInvariant(), h => h.Value.ToUpperInvariant()));
int hashCode = 17;
foreach (KeyValuePair<string, string> kvp in sorted)
{
hashCode = (hashCode * 31) + StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Key);
hashCode = (hashCode * 31) + StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Value);
}
return hashCode.ToString(CultureInfo.InvariantCulture);
}
private static void PopulateResultContent(McpServerToolResultContent resultContent, CallToolResult result)
{
// Ensure Output list is initialized
resultContent.Output ??= [];
if (result.IsError == true)
{
// Collect error text from content blocks
string? errorText = null;
if (result.Content is not null)
{
foreach (ContentBlock block in result.Content)
{
if (block is TextContentBlock textBlock)
{
errorText = errorText is null ? textBlock.Text : $"{errorText}\n{textBlock.Text}";
}
}
}
resultContent.Output.Add(new TextContent($"Error: {errorText ?? "Unknown error from MCP Server call"}"));
return;
}
if (result.Content is null || result.Content.Count == 0)
{
return;
}
// Map each MCP content block to an MEAI AIContent type
foreach (ContentBlock block in result.Content)
{
AIContent content = ConvertContentBlock(block);
if (content is not null)
{
resultContent.Output.Add(content);
}
}
}
private static AIContent ConvertContentBlock(ContentBlock block)
{
return block switch
{
TextContentBlock text => new TextContent(text.Text),
ImageContentBlock image => CreateDataContentFromBase64(image.Data, image.MimeType ?? "image/*"),
AudioContentBlock audio => CreateDataContentFromBase64(audio.Data, audio.MimeType ?? "audio/*"),
_ => new TextContent(block.ToString() ?? string.Empty),
};
}
private static DataContent CreateDataContentFromBase64(string? base64Data, string mediaType)
{
if (string.IsNullOrEmpty(base64Data))
{
return new DataContent($"data:{mediaType};base64,", mediaType);
}
// If it's already a data URI, use it directly
if (base64Data.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
return new DataContent(base64Data, mediaType);
}
// Otherwise, construct a data URI from the base64 data
return new DataContent($"data:{mediaType};base64,{base64Data}", mediaType);
}
}
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsReleaseCandidate>true</IsReleaseCandidate>
<NoWarn>$(NoWarn);MEAI001;OPENAI001</NoWarn>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework Declarative Workflows MCP</Title>
<Description>Provides Microsoft Agent Framework support for MCP (Model Context Protocol) server integration in declarative workflows.</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ModelContextProtocol" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests" />
</ItemGroup>
</Project>
@@ -20,6 +20,12 @@ public sealed class DeclarativeWorkflowOptions(ResponseAgentProvider agentProvid
/// </summary>
public ResponseAgentProvider AgentProvider { get; } = Throw.IfNull(agentProvider);
/// <summary>
/// Gets or sets the MCP tool handler for invoking MCP tools within workflows.
/// If not set, MCP tool invocations will fail with an appropriate error message.
/// </summary>
public IMcpToolHandler? McpToolHandler { get; init; }
/// <summary>
/// Defines the configuration settings for the workflow.
/// </summary>
@@ -42,6 +42,40 @@ internal static class JsonDocumentExtensions
};
}
/// <summary>
/// Creates a VariableType.List with schema inferred from the first object element in the array.
/// </summary>
public static VariableType GetListTypeFromJson(this JsonElement arrayElement)
{
// Find the first object element to infer schema
foreach (JsonElement element in arrayElement.EnumerateArray())
{
if (element.ValueKind == JsonValueKind.Object)
{
// Build schema from the object's properties
List<(string Key, VariableType Type)> fields = [];
foreach (JsonProperty property in element.EnumerateObject())
{
VariableType fieldType = property.Value.ValueKind switch
{
JsonValueKind.String => typeof(string),
JsonValueKind.Number => typeof(decimal),
JsonValueKind.True or JsonValueKind.False => typeof(bool),
JsonValueKind.Object => VariableType.RecordType,
JsonValueKind.Array => VariableType.ListType,
_ => typeof(string),
};
fields.Add((property.Name, fieldType));
}
return VariableType.List(fields);
}
}
// Fallback for arrays of primitives or empty arrays
return VariableType.ListType;
}
private static Dictionary<string, object?> ParseRecord(this JsonElement currentElement, VariableType targetType)
{
IEnumerable<KeyValuePair<string, object?>> keyValuePairs =
@@ -118,6 +152,7 @@ internal static class JsonDocumentExtensions
JsonValueKind.True => typeof(bool),
JsonValueKind.False => typeof(bool),
JsonValueKind.Number => typeof(decimal),
JsonValueKind.Array => (VariableType)VariableType.ListType, // Add support for nested arrays
_ => null,
};
@@ -285,9 +320,16 @@ internal static class JsonDocumentExtensions
private static bool TryParseList(JsonElement propertyElement, VariableType? targetType, out object? value)
{
// Handle empty arrays without needing to determine element type
if (propertyElement.GetArrayLength() == 0)
{
value = new List<object?>();
return true;
}
try
{
value = ParseTable(propertyElement, targetType ?? VariableType.ListType);
value = ParseTable(propertyElement, targetType ?? GetListTypeFromJson(propertyElement));
return true;
}
catch
@@ -0,0 +1,41 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative;
/// <summary>
/// Defines the contract for invoking MCP tools within declarative workflows.
/// </summary>
/// <remarks>
/// This interface allows the MCP tool invocation to be abstracted, enabling
/// different implementations for local development, hosted workflows, and testing scenarios.
/// </remarks>
public interface IMcpToolHandler
{
/// <summary>
/// Invokes an MCP tool on the specified server.
/// </summary>
/// <param name="serverUrl">The URL of the MCP server.</param>
/// <param name="serverLabel">An optional label identifying the server connection.</param>
/// <param name="toolName">The name of the tool to invoke.</param>
/// <param name="arguments">Optional arguments to pass to the tool.</param>
/// <param name="headers">Optional headers to include in the request.</param>
/// <param name="connectionName">An optional connection name for managed connections.</param>
/// <param name="cancellationToken">A token to observe cancellation.</param>
/// <returns>
/// A task representing the asynchronous operation. The result contains a <see cref="McpServerToolResultContent"/>
/// with the tool invocation output.
/// </returns>
Task<McpServerToolResultContent> InvokeToolAsync(
string serverUrl,
string? serverLabel,
string toolName,
IDictionary<string, object?>? arguments,
IDictionary<string, string>? headers,
string? connectionName,
CancellationToken cancellationToken = default);
}
@@ -493,6 +493,42 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
this.ContinueWith(new SendActivityExecutor(item, this._workflowState));
}
protected override void Visit(InvokeMcpTool item)
{
this.Trace(item);
// Verify MCP handler is configured
if (this._workflowOptions.McpToolHandler is null)
{
throw new DeclarativeModelException("MCP tool handler not configured. Set McpToolHandler in DeclarativeWorkflowOptions to use InvokeMcpTool actions.");
}
// Entry point to invoke MCP tool - may yield for approval
InvokeMcpToolExecutor action = new(item, this._workflowOptions.McpToolHandler, this._workflowOptions.AgentProvider, this._workflowState);
this.ContinueWith(action);
// Transition to post action if no external input is required (no approval needed)
string postId = Steps.Post(action.Id);
this._workflowModel.AddLink(action.Id, postId, InvokeMcpToolExecutor.RequiresNothing);
// If approval is required, define request-port for approval flow
string externalInputPortId = InvokeMcpToolExecutor.Steps.ExternalInput(action.Id);
RequestPortAction externalInputPort = new(RequestPort.Create<ExternalInputRequest, ExternalInputResponse>(externalInputPortId));
this._workflowModel.AddNode(externalInputPort, action.ParentId);
this._workflowModel.AddLink(action.Id, externalInputPortId, InvokeMcpToolExecutor.RequiresInput);
// Capture response when external input is received
string resumeId = InvokeMcpToolExecutor.Steps.Resume(action.Id);
this._workflowModel.AddNode(new DelegateActionExecutor<ExternalInputResponse>(resumeId, this._workflowState, action.CaptureResponseAsync), action.ParentId);
this._workflowModel.AddLink(externalInputPortId, resumeId);
// After resume, transition to post action
this._workflowModel.AddLink(resumeId, postId);
// Define post action (completion)
this._workflowModel.AddNode(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), action.ParentId);
}
#region Not supported
protected override void Visit(AnswerQuestionWithAI item) => this.NotSupported(item);
@@ -365,6 +365,8 @@ internal sealed class WorkflowTemplateVisitor : DialogActionVisitor
#region Not supported
protected override void Visit(InvokeMcpTool item) => this.NotSupported(item);
protected override void Visit(InvokeFunctionTool item) => this.NotSupported(item);
protected override void Visit(AnswerQuestionWithAI item) => this.NotSupported(item);
@@ -204,7 +204,7 @@ internal sealed class InvokeFunctionToolExecutor(
object? parsedValue = jsonDocument.RootElement.ValueKind switch
{
JsonValueKind.Object => jsonDocument.ParseRecord(VariableType.RecordType),
JsonValueKind.Array => jsonDocument.ParseList(CreateListTypeFromJson(jsonDocument.RootElement)),
JsonValueKind.Array => jsonDocument.ParseList(jsonDocument.RootElement.GetListTypeFromJson()),
JsonValueKind.String => jsonDocument.RootElement.GetString(),
JsonValueKind.Number => jsonDocument.RootElement.TryGetInt64(out long l) ? l : jsonDocument.RootElement.GetDouble(),
JsonValueKind.True => true,
@@ -224,40 +224,6 @@ internal sealed class InvokeFunctionToolExecutor(
await this.AssignAsync(this.Model.Output.Result?.Path, resultValue.ToFormula(), context).ConfigureAwait(false);
}
/// <summary>
/// Creates a VariableType.List with schema inferred from the first object element in the array.
/// </summary>
private static VariableType CreateListTypeFromJson(JsonElement arrayElement)
{
// Find the first object element to infer schema
foreach (JsonElement element in arrayElement.EnumerateArray())
{
if (element.ValueKind == JsonValueKind.Object)
{
// Build schema from the object's properties
List<(string Key, VariableType Type)> fields = [];
foreach (JsonProperty property in element.EnumerateObject())
{
VariableType fieldType = property.Value.ValueKind switch
{
JsonValueKind.String => typeof(string),
JsonValueKind.Number => typeof(decimal),
JsonValueKind.True or JsonValueKind.False => typeof(bool),
JsonValueKind.Object => VariableType.RecordType,
JsonValueKind.Array => VariableType.ListType,
_ => typeof(string),
};
fields.Add((property.Name, fieldType));
}
return VariableType.List(fields);
}
}
// Fallback for arrays of primitives or empty arrays
return VariableType.ListType;
}
private string GetFunctionName() =>
this.Evaluator.GetValue(
Throw.IfNull(
@@ -0,0 +1,367 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
/// <summary>
/// Executor for the <see cref="InvokeMcpTool"/> action.
/// This executor invokes MCP tools on remote servers and handles approval flows.
/// </summary>
internal sealed class InvokeMcpToolExecutor(
InvokeMcpTool model,
IMcpToolHandler mcpToolHandler,
ResponseAgentProvider agentProvider,
WorkflowFormulaState state) :
DeclarativeActionExecutor<InvokeMcpTool>(model, state)
{
/// <summary>
/// Step identifiers for the MCP tool invocation workflow.
/// </summary>
public static class Steps
{
/// <summary>
/// Step for waiting for external input (approval or direct response).
/// </summary>
public static string ExternalInput(string id) => $"{id}_{nameof(ExternalInput)}";
/// <summary>
/// Step for resuming after receiving external input.
/// </summary>
public static string Resume(string id) => $"{id}_{nameof(Resume)}";
}
/// <summary>
/// Determines if the message indicates external input is required.
/// </summary>
public static bool RequiresInput(object? message) => message is ExternalInputRequest;
/// <summary>
/// Determines if the message indicates no external input is required.
/// </summary>
public static bool RequiresNothing(object? message) => message is ActionExecutorResult;
/// <inheritdoc/>
protected override bool EmitResultEvent => false;
/// <inheritdoc/>
protected override bool IsDiscreteAction => false;
/// <inheritdoc/>
[SendsMessage(typeof(ExternalInputRequest))]
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
string serverUrl = this.GetServerUrl();
string? serverLabel = this.GetServerLabel();
string toolName = this.GetToolName();
bool requireApproval = this.GetRequireApproval();
Dictionary<string, object?>? arguments = this.GetArguments();
Dictionary<string, string>? headers = this.GetHeaders();
string? connectionName = this.GetConnectionName();
if (requireApproval)
{
// Create tool call content for approval request
McpServerToolCallContent toolCall = new(this.Id, toolName, serverLabel ?? serverUrl)
{
Arguments = arguments
};
if (headers != null)
{
toolCall.AdditionalProperties ??= [];
toolCall.AdditionalProperties.Add(headers);
}
McpServerToolApprovalRequestContent approvalRequest = new(this.Id, toolCall);
ChatMessage requestMessage = new(ChatRole.Assistant, [approvalRequest]);
AgentResponse agentResponse = new([requestMessage]);
// Yield to the caller for approval
ExternalInputRequest inputRequest = new(agentResponse);
await context.SendMessageAsync(inputRequest, cancellationToken).ConfigureAwait(false);
return default;
}
// No approval required - invoke the tool directly
McpServerToolResultContent resultContent = await mcpToolHandler.InvokeToolAsync(
serverUrl,
serverLabel,
toolName,
arguments,
headers,
connectionName,
cancellationToken).ConfigureAwait(false);
await this.ProcessResultAsync(context, resultContent, cancellationToken).ConfigureAwait(false);
// Signal completion so the workflow routes via RequiresNothing
await context.SendResultMessageAsync(this.Id, result: null, cancellationToken).ConfigureAwait(false);
return default;
}
/// <summary>
/// Captures the external input response and processes the MCP tool result.
/// </summary>
/// <param name="context">The workflow context.</param>
/// <param name="response">The external input response.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask CaptureResponseAsync(
IWorkflowContext context,
ExternalInputResponse response,
CancellationToken cancellationToken)
{
// Check for approval response
McpServerToolApprovalResponseContent? approvalResponse = response.Messages
.SelectMany(m => m.Contents)
.OfType<McpServerToolApprovalResponseContent>()
.FirstOrDefault(r => r.Id == this.Id);
if (approvalResponse?.Approved != true)
{
// Tool call was rejected
await this.AssignErrorAsync(context, "MCP tool invocation was not approved by user.").ConfigureAwait(false);
return;
}
// Approved - now invoke the tool
string serverUrl = this.GetServerUrl();
string? serverLabel = this.GetServerLabel();
string toolName = this.GetToolName();
Dictionary<string, object?>? arguments = this.GetArguments();
Dictionary<string, string>? headers = this.GetHeaders();
string? connectionName = this.GetConnectionName();
McpServerToolResultContent resultContent = await mcpToolHandler.InvokeToolAsync(
serverUrl,
serverLabel,
toolName,
arguments,
headers,
connectionName,
cancellationToken).ConfigureAwait(false);
await this.ProcessResultAsync(context, resultContent, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Completes the MCP tool invocation by raising the completion event.
/// </summary>
public async ValueTask CompleteAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken)
{
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
}
private async ValueTask ProcessResultAsync(IWorkflowContext context, McpServerToolResultContent resultContent, CancellationToken cancellationToken)
{
bool autoSend = this.GetAutoSendValue();
string? conversationId = this.GetConversationId();
await this.AssignResultAsync(context, resultContent).ConfigureAwait(false);
ChatMessage resultMessage = new(ChatRole.Tool, resultContent.Output);
// Store messages if output path is configured
if (this.Model.Output?.Messages is not null)
{
await this.AssignAsync(this.Model.Output.Messages?.Path, resultMessage.ToFormula(), context).ConfigureAwait(false);
}
// Auto-send the result if configured
if (autoSend)
{
AgentResponse resultResponse = new([resultMessage]);
await context.AddEventAsync(new AgentResponseEvent(this.Id, resultResponse), cancellationToken).ConfigureAwait(false);
}
// Add messages to conversation if conversationId is provided
if (conversationId is not null)
{
ChatMessage assistantMessage = new(ChatRole.Assistant, resultContent.Output);
await agentProvider.CreateMessageAsync(conversationId, assistantMessage, cancellationToken).ConfigureAwait(false);
}
}
private async ValueTask AssignResultAsync(IWorkflowContext context, McpServerToolResultContent toolResult)
{
if (this.Model.Output?.Result is null || toolResult.Output is null || toolResult.Output.Count == 0)
{
return;
}
List<object?> parsedResults = [];
foreach (AIContent resultContent in toolResult.Output)
{
object? resultValue = resultContent switch
{
TextContent text => text.Text,
DataContent data => data.Uri,
_ => resultContent.ToString(),
};
// Convert JsonElement to its raw JSON string for processing
if (resultValue is JsonElement jsonElement)
{
resultValue = jsonElement.GetRawText();
}
// Attempt to parse as JSON if it's a string (or was converted from JsonElement)
if (resultValue is string jsonString)
{
try
{
using JsonDocument jsonDocument = JsonDocument.Parse(jsonString);
// Handle different JSON value kinds
object? parsedValue = jsonDocument.RootElement.ValueKind switch
{
JsonValueKind.Object => jsonDocument.ParseRecord(VariableType.RecordType),
JsonValueKind.Array => jsonDocument.ParseList(jsonDocument.RootElement.GetListTypeFromJson()),
JsonValueKind.String => jsonDocument.RootElement.GetString(),
JsonValueKind.Number => jsonDocument.RootElement.TryGetInt64(out long l) ? l : jsonDocument.RootElement.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
_ => jsonString,
};
parsedResults.Add(parsedValue);
continue;
}
catch (JsonException)
{
// Not a valid JSON
}
}
parsedResults.Add(resultValue);
}
await this.AssignAsync(this.Model.Output.Result?.Path, parsedResults.ToFormula(), context).ConfigureAwait(false);
}
private async ValueTask AssignErrorAsync(IWorkflowContext context, string errorMessage)
{
// Store error in result if configured (as a simple string)
if (this.Model.Output?.Result is not null)
{
await this.AssignAsync(this.Model.Output.Result?.Path, $"Error: {errorMessage}".ToFormula(), context).ConfigureAwait(false);
}
}
private string GetServerUrl() =>
this.Evaluator.GetValue(
Throw.IfNull(
this.Model.ServerUrl,
$"{nameof(this.Model)}.{nameof(this.Model.ServerUrl)}")).Value;
private string? GetServerLabel()
{
if (this.Model.ServerLabel is null)
{
return null;
}
string value = this.Evaluator.GetValue(this.Model.ServerLabel).Value;
return value.Length == 0 ? null : value;
}
private string GetToolName() =>
this.Evaluator.GetValue(
Throw.IfNull(
this.Model.ToolName,
$"{nameof(this.Model)}.{nameof(this.Model.ToolName)}")).Value;
private string? GetConversationId()
{
if (this.Model.ConversationId is null)
{
return null;
}
string value = this.Evaluator.GetValue(this.Model.ConversationId).Value;
return value.Length == 0 ? null : value;
}
private bool GetRequireApproval()
{
if (this.Model.RequireApproval is null)
{
return false;
}
return this.Evaluator.GetValue(this.Model.RequireApproval).Value;
}
private bool GetAutoSendValue()
{
if (this.Model.Output?.AutoSend is null)
{
return true;
}
return this.Evaluator.GetValue(this.Model.Output.AutoSend).Value;
}
private string? GetConnectionName()
{
if (this.Model.Connection?.Name is null)
{
return null;
}
string value = this.Evaluator.GetValue(this.Model.Connection.Name).Value;
return value.Length == 0 ? null : value;
}
private Dictionary<string, object?>? GetArguments()
{
if (this.Model.Arguments is null)
{
return null;
}
Dictionary<string, object?> result = [];
foreach (KeyValuePair<string, ValueExpression> argument in this.Model.Arguments)
{
result[argument.Key] = this.Evaluator.GetValue(argument.Value).Value.ToObject();
}
return result;
}
private Dictionary<string, string>? GetHeaders()
{
if (this.Model.Headers is null)
{
return null;
}
Dictionary<string, string> result = [];
foreach (KeyValuePair<string, StringExpression> header in this.Model.Headers)
{
string value = this.Evaluator.GetValue(header.Value).Value;
if (!string.IsNullOrEmpty(value))
{
result[header.Key] = value;
}
}
return result;
}
}
@@ -22,6 +22,9 @@ internal sealed class WorkflowFactory(string workflowFile, Uri foundryEndpoint)
// Assign to enable logging
public ILoggerFactory LoggerFactory { get; init; } = NullLoggerFactory.Instance;
// Assign to provide MCP tool capabilities
public IMcpToolHandler? McpToolHandler { get; init; }
/// <summary>
/// Create the workflow from the declarative YAML. Includes definition of the
/// <see cref="DeclarativeWorkflowOptions" /> and the associated <see cref="ResponseAgentProvider"/>.
@@ -42,6 +45,7 @@ internal sealed class WorkflowFactory(string workflowFile, Uri foundryEndpoint)
Configuration = this.Configuration,
ConversationId = this.ConversationId,
LoggerFactory = this.LoggerFactory,
McpToolHandler = this.McpToolHandler,
};
string workflowPath = Path.Combine(AppContext.BaseDirectory, workflowFile);
@@ -61,6 +61,11 @@ public abstract class IntegrationTest : IDisposable
internal static string FormatVariablePath(string variableName, string? scope = null) => $"{scope ?? WorkflowFormulaState.DefaultScopeName}.{variableName}";
protected async ValueTask<DeclarativeWorkflowOptions> CreateOptionsAsync(bool externalConversation = false, params IEnumerable<AIFunction> functionTools)
{
return await this.CreateOptionsAsync(externalConversation, mcpToolProvider: null, functionTools).ConfigureAwait(false);
}
protected async ValueTask<DeclarativeWorkflowOptions> CreateOptionsAsync(bool externalConversation, IMcpToolHandler? mcpToolProvider, params IEnumerable<AIFunction> functionTools)
{
AzureAgentProvider agentProvider =
new(this.TestEndpoint, new AzureCliCredential())
@@ -78,7 +83,8 @@ public abstract class IntegrationTest : IDisposable
new DeclarativeWorkflowOptions(agentProvider)
{
ConversationId = conversationId,
LoggerFactory = this.Output
LoggerFactory = this.Output,
McpToolHandler = mcpToolProvider
};
}
@@ -10,31 +10,48 @@ using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents;
using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.Mcp;
using Microsoft.Extensions.AI;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests;
/// <summary>
/// Integration tests for InvokeFunctionTool action.
/// This test pattern can be extended for other InvokeTool types.
/// Integration tests for InvokeFunctionTool and InvokeMcpTool actions.
/// </summary>
public sealed class InvokeFunctionToolWorkflowTest(ITestOutputHelper output) : IntegrationTest(output)
public sealed class InvokeToolWorkflowTest(ITestOutputHelper output) : IntegrationTest(output)
{
#region InvokeFunctionTool Tests
[Theory]
[InlineData("InvokeFunctionTool.yaml", new string[] { "GetSpecials", "GetItemPrice" }, "2.95")]
[InlineData("InvokeFunctionToolWithApproval.yaml", new string[] { "GetItemPrice" }, "4.9")]
public Task ValidateInvokeFunctionToolAsync(string workflowFileName, string[] expectedFunctionCalls, string? expectedResultContains) =>
this.RunInvokeToolTestAsync(workflowFileName, expectedFunctionCalls, expectedResultContains);
this.RunInvokeFunctionToolTestAsync(workflowFileName, expectedFunctionCalls, expectedResultContains);
#endregion
#region InvokeMcpTool Tests
[Theory]
[InlineData("InvokeMcpTool.yaml", "Azure OpenAI")]
public Task ValidateInvokeMcpToolAsync(string workflowFileName, string? expectedResultContains) =>
this.RunInvokeMcpToolTestAsync(workflowFileName, expectedResultContains, requireApproval: false);
[Theory]
[InlineData("InvokeMcpToolWithApproval.yaml", "Azure OpenAI", true)]
[InlineData("InvokeMcpToolWithApproval.yaml", "MCP tool invocation was not approved by user", false)]
public Task ValidateInvokeMcpToolWithApprovalAsync(string workflowFileName, string? expectedResultContains, bool approveRequest) =>
this.RunInvokeMcpToolTestAsync(workflowFileName, expectedResultContains, requireApproval: true, approveRequest: approveRequest);
#endregion
#region InvokeFunctionTool Test Helpers
/// <summary>
/// Runs an InvokeTool workflow test with the specified configuration.
/// This method is designed to be generic and reusable for different InvokeTool types.
/// Runs an InvokeFunctionTool workflow test with the specified configuration.
/// </summary>
/// <param name="workflowFileName">The workflow YAML file name.</param>
/// <param name="expectedFunctionCalls">Expected function names to be called in order.</param>
/// <param name="expectedResultContains">Expected text to be present in the final result.</param>
private async Task RunInvokeToolTestAsync(
private async Task RunInvokeFunctionToolTestAsync(
string workflowFileName,
string[] expectedFunctionCalls,
string? expectedResultContains = null)
@@ -72,7 +89,6 @@ public sealed class InvokeFunctionToolWorkflowTest(ITestOutputHelper output) : I
// Continue processing until there are no more pending input events from the resumed workflow
if (resumeEvents.InputEvents.Count == 0)
{
// No more input events from the last resume - workflow completed
break;
}
}
@@ -85,19 +101,12 @@ public sealed class InvokeFunctionToolWorkflowTest(ITestOutputHelper output) : I
}
// Assert - Verify executor and action events
Assert.NotEmpty(workflowEvents.ExecutorInvokeEvents);
Assert.NotEmpty(workflowEvents.ExecutorCompleteEvents);
Assert.NotEmpty(workflowEvents.ActionInvokeEvents);
AssertWorkflowEventsEmitted(workflowEvents);
// Assert - Verify expected result if specified
if (expectedResultContains is not null)
{
MessageActivityEvent? messageEvent = workflowEvents.Events
.OfType<MessageActivityEvent>()
.LastOrDefault();
Assert.NotNull(messageEvent);
Assert.Contains(expectedResultContains, messageEvent.Message, StringComparison.OrdinalIgnoreCase);
AssertResultContains(workflowEvents, expectedResultContains);
}
}
@@ -150,6 +159,119 @@ public sealed class InvokeFunctionToolWorkflowTest(ITestOutputHelper output) : I
return results;
}
#endregion
#region InvokeMcpTool Test Helpers
/// <summary>
/// Runs an InvokeMcpTool workflow test with the specified configuration.
/// </summary>
private async Task RunInvokeMcpToolTestAsync(
string workflowFileName,
string? expectedResultContains = null,
bool requireApproval = false,
bool approveRequest = true)
{
// Arrange
string workflowPath = GetWorkflowPath(workflowFileName);
DefaultMcpToolHandler mcpToolProvider = new();
DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync(
externalConversation: false,
mcpToolProvider: mcpToolProvider);
Workflow workflow = DeclarativeWorkflowBuilder.Build<string>(workflowPath, workflowOptions);
WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(workflowPath));
// Act - Run workflow and handle MCP tool invocations
WorkflowEvents workflowEvents = await harness.RunWorkflowAsync("start").ConfigureAwait(false);
while (workflowEvents.InputEvents.Count > 0)
{
RequestInfoEvent inputEvent = workflowEvents.InputEvents[^1];
ExternalInputRequest? toolRequest = inputEvent.Request.Data.As<ExternalInputRequest>();
Assert.NotNull(toolRequest);
IList<AIContent> mcpResults = this.ProcessMcpToolRequests(
toolRequest,
approveRequest);
ChatMessage resultMessage = new(ChatRole.Tool, mcpResults);
WorkflowEvents resumeEvents = await harness.ResumeAsync(
inputEvent.Request.CreateResponse(new ExternalInputResponse(resultMessage))).ConfigureAwait(false);
workflowEvents = new WorkflowEvents([.. workflowEvents.Events, .. resumeEvents.Events]);
// Continue processing until there are no more pending input events from the resumed workflow
if (resumeEvents.InputEvents.Count == 0)
{
break;
}
}
// Assert - Verify executor and action events
AssertWorkflowEventsEmitted(workflowEvents);
// Assert - Verify expected result if specified
if (expectedResultContains is not null)
{
AssertResultContains(workflowEvents, expectedResultContains);
}
// Cleanup
await mcpToolProvider.DisposeAsync().ConfigureAwait(false);
}
/// <summary>
/// Processes MCP tool requests from an external input request.
/// Handles approval requests for MCP tools.
/// </summary>
private List<AIContent> ProcessMcpToolRequests(
ExternalInputRequest toolRequest,
bool approveRequest)
{
List<AIContent> results = [];
foreach (ChatMessage message in toolRequest.AgentResponse.Messages)
{
// Handle MCP approval requests if present
foreach (McpServerToolApprovalRequestContent approvalRequest in message.Contents.OfType<McpServerToolApprovalRequestContent>())
{
this.Output.WriteLine($"MCP APPROVAL REQUEST: {approvalRequest.Id}");
// Respond based on test configuration
McpServerToolApprovalResponseContent response = approvalRequest.CreateResponse(approved: approveRequest);
results.Add(response);
this.Output.WriteLine($"MCP APPROVAL RESPONSE: {(approveRequest ? "Approved" : "Rejected")}");
}
}
return results;
}
#endregion
#region Shared Helpers
private static void AssertWorkflowEventsEmitted(WorkflowEvents workflowEvents)
{
Assert.NotEmpty(workflowEvents.ExecutorInvokeEvents);
Assert.NotEmpty(workflowEvents.ExecutorCompleteEvents);
Assert.NotEmpty(workflowEvents.ActionInvokeEvents);
}
private static void AssertResultContains(WorkflowEvents workflowEvents, string expectedResultContains)
{
MessageActivityEvent? messageEvent = workflowEvents.Events
.OfType<MessageActivityEvent>()
.LastOrDefault();
Assert.NotNull(messageEvent);
Assert.Contains(expectedResultContains, messageEvent.Message, StringComparison.OrdinalIgnoreCase);
}
private static string GetWorkflowPath(string workflowFileName) =>
Path.Combine(Environment.CurrentDirectory, "Workflows", workflowFileName);
#endregion
}
@@ -10,6 +10,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Workflows.Declarative.AzureAI\Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Workflows.Declarative.Mcp\Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,35 @@
#
# This workflow tests invoking MCP tools directly from a workflow.
# Uses the Microsoft Learn MCP server: search tool
#
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_invoke_mcp_tool_test
actions:
# Set the search query we want to use
- kind: SetVariable
id: set_search_query
variable: Local.SearchQuery
value: Azure OpenAI
# Invoke MCP search tool on Microsoft Learn server
- kind: InvokeMcpTool
id: invoke_mcp_search
serverUrl: https://learn.microsoft.com/api/mcp
serverLabel: microsoft_docs
toolName: microsoft_docs_search
conversationId: =System.ConversationId
arguments:
query: =Local.SearchQuery
output:
autoSend: true
result: Local.SearchResult
# Send the result as an activity
- kind: SendMessage
id: show_search_result
message: "Search results: {Local.SearchResult}"
# message: "Search results for {Local.SearchQuery}: {Local.SearchResult}"
@@ -0,0 +1,35 @@
#
# This workflow tests invoking MCP tools with approval requirement.
# Uses the Microsoft Learn MCP server: search tool with requireApproval: true
#
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_invoke_mcp_tool_approval_test
actions:
# Set the search query we want to use
- kind: SetVariable
id: set_search_query
variable: Local.ContentUrl
value: https://learn.microsoft.com/azure/ai-foundry/openai/concepts/use-your-data
# Invoke MCP search tool with approval requirement
- kind: InvokeMcpTool
id: invoke_mcp_search
serverUrl: https://learn.microsoft.com/api/mcp
serverLabel: MicrosoftLearn
toolName: microsoft_docs_fetch
requireApproval: true
arguments:
url: =Local.ContentUrl
output:
autoSend: false
result: Local.FetchResult
messages: Local.FetchMessages
# Send the result as an activity
- kind: SendMessage
id: show_search_result
message: "Content for {Local.ContentUrl}: {Local.FetchResult}"
@@ -0,0 +1,345 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
namespace Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests;
/// <summary>
/// Unit tests for <see cref="DefaultMcpToolHandler"/>.
/// </summary>
public sealed class DefaultMcpToolHandlerTests
{
#region Constructor Tests
[Fact]
public async Task Constructor_WithNoParameters_ShouldCreateInstanceAsync()
{
// Act
DefaultMcpToolHandler handler = new();
// Assert
handler.Should().NotBeNull();
await handler.DisposeAsync();
}
[Fact]
public async Task Constructor_WithNullHttpClientProvider_ShouldCreateInstanceAsync()
{
// Act
DefaultMcpToolHandler handler = new(httpClientProvider: null);
// Assert
handler.Should().NotBeNull();
await handler.DisposeAsync();
}
[Fact]
public async Task Constructor_WithHttpClientProvider_ShouldCreateInstanceAsync()
{
// Arrange
static Task<HttpClient?> ProviderAsync(string url, CancellationToken ct) => Task.FromResult<HttpClient?>(new HttpClient());
// Act
DefaultMcpToolHandler handler = new(httpClientProvider: ProviderAsync);
// Assert
handler.Should().NotBeNull();
await handler.DisposeAsync();
}
#endregion
#region DisposeAsync Tests
[Fact]
public async Task DisposeAsync_WhenCalled_ShouldCompleteWithoutErrorAsync()
{
// Arrange
DefaultMcpToolHandler handler = new();
// Act
Func<Task> act = async () => await handler.DisposeAsync();
// Assert
await act.Should().NotThrowAsync();
}
[Fact]
public async Task DisposeAsync_WhenCalledMultipleTimes_ShouldHandleGracefullyAsync()
{
// Arrange
DefaultMcpToolHandler handler = new();
// Act
await handler.DisposeAsync();
Func<Task> act = async () => await handler.DisposeAsync();
// Assert - Second dispose should throw ObjectDisposedException from the semaphore
await act.Should().ThrowAsync<ObjectDisposedException>();
}
#endregion
#region HttpClientProvider Tests
[Fact]
public async Task InvokeToolAsync_WithHttpClientProvider_ShouldCallProviderAsync()
{
// Arrange
bool providerCalled = false;
string? capturedServerUrl = null;
Task<HttpClient?> ProviderAsync(string url, CancellationToken ct)
{
providerCalled = true;
capturedServerUrl = url;
return Task.FromResult<HttpClient?>(null);
}
DefaultMcpToolHandler handler = new(httpClientProvider: ProviderAsync);
// Act & Assert - The call will fail because there's no real MCP server, but the provider should be called
try
{
await handler.InvokeToolAsync(
serverUrl: "http://localhost:12345/mcp",
serverLabel: "test",
toolName: "testTool",
arguments: null,
headers: null,
connectionName: null);
}
catch
{
// Expected to fail - no real server
}
finally
{
await handler.DisposeAsync();
}
// Assert
providerCalled.Should().BeTrue();
capturedServerUrl.Should().Be("http://localhost:12345/mcp");
}
[Fact]
public async Task InvokeToolAsync_WithHttpClientProviderReturningClient_ShouldUseProvidedClientAsync()
{
// Arrange
bool providerCalled = false;
HttpClient? providedClient = null;
Task<HttpClient?> ProviderAsync(string url, CancellationToken ct)
{
providerCalled = true;
providedClient = new HttpClient();
return Task.FromResult<HttpClient?>(providedClient);
}
DefaultMcpToolHandler handler = new(httpClientProvider: ProviderAsync);
// Act & Assert - The call will fail because there's no real MCP server, but the provider should be called
try
{
await handler.InvokeToolAsync(
serverUrl: "http://localhost:12345/mcp",
serverLabel: "test",
toolName: "testTool",
arguments: null,
headers: null,
connectionName: null);
}
catch
{
// Expected to fail - no real server
}
finally
{
await handler.DisposeAsync();
providedClient?.Dispose();
}
// Assert
providerCalled.Should().BeTrue();
}
#endregion
#region Caching Tests
[Fact]
public async Task InvokeToolAsync_SameServerUrl_ShouldCallProviderOncePerAttemptWhenConnectionFailsAsync()
{
// Arrange
int providerCallCount = 0;
Task<HttpClient?> ProviderAsync(string url, CancellationToken ct)
{
providerCallCount++;
return Task.FromResult<HttpClient?>(null);
}
DefaultMcpToolHandler handler = new(httpClientProvider: ProviderAsync);
const string ServerUrl = "http://localhost:12345/mcp";
try
{
// Act - Call twice with the same server URL
// Since there's no real server, the McpClient.CreateAsync will fail,
// so the client won't be cached and the provider will be called each time
for (int i = 0; i < 2; i++)
{
try
{
await handler.InvokeToolAsync(
serverUrl: ServerUrl,
serverLabel: "test",
toolName: "testTool",
arguments: null,
headers: null,
connectionName: null);
}
catch
{
// Expected to fail - no real server
}
}
// Assert - Provider is called each time because McpClient creation fails before caching
providerCallCount.Should().Be(2);
}
finally
{
await handler.DisposeAsync();
}
}
[Fact]
public async Task InvokeToolAsync_DifferentServerUrls_ShouldCreateSeparateClientsAsync()
{
// Arrange
int providerCallCount = 0;
Task<HttpClient?> ProviderAsync(string url, CancellationToken ct)
{
providerCallCount++;
return Task.FromResult<HttpClient?>(null);
}
DefaultMcpToolHandler handler = new(httpClientProvider: ProviderAsync);
try
{
// Act - Call with different server URLs
foreach (string serverUrl in new[] { "http://localhost:12345/mcp1", "http://localhost:12345/mcp2" })
{
try
{
await handler.InvokeToolAsync(
serverUrl: serverUrl,
serverLabel: "test",
toolName: "testTool",
arguments: null,
headers: null,
connectionName: null);
}
catch
{
// Expected to fail - no real server
}
}
// Assert - Provider should be called once per unique server URL
providerCallCount.Should().Be(2);
}
finally
{
await handler.DisposeAsync();
}
}
[Fact]
public async Task InvokeToolAsync_SameUrlDifferentHeaders_ShouldCreateSeparateClientsAsync()
{
// Arrange
int providerCallCount = 0;
Task<HttpClient?> ProviderAsync(string url, CancellationToken ct)
{
providerCallCount++;
return Task.FromResult<HttpClient?>(null);
}
DefaultMcpToolHandler handler = new(httpClientProvider: ProviderAsync);
const string ServerUrl = "http://localhost:12345/mcp";
try
{
// Act - Call with same URL but different headers
Dictionary<string, string>[] headerSets =
[
new() { ["Authorization"] = "Bearer token1" },
new() { ["Authorization"] = "Bearer token2" }
];
foreach (Dictionary<string, string> headers in headerSets)
{
try
{
await handler.InvokeToolAsync(
serverUrl: ServerUrl,
serverLabel: "test",
toolName: "testTool",
arguments: null,
headers: headers,
connectionName: null);
}
catch
{
// Expected to fail - no real server
}
}
// Assert - Different headers should create different cache keys
providerCallCount.Should().Be(2);
}
finally
{
await handler.DisposeAsync();
}
}
#endregion
#region Interface Implementation Tests
[Fact]
public async Task DefaultMcpToolHandler_ShouldImplementIMcpToolHandlerAsync()
{
// Arrange & Act
DefaultMcpToolHandler handler = new();
// Assert
handler.Should().BeAssignableTo<IMcpToolHandler>();
await handler.DisposeAsync();
}
[Fact]
public async Task DefaultMcpToolHandler_ShouldImplementIAsyncDisposableAsync()
{
// Arrange & Act
DefaultMcpToolHandler handler = new();
// Assert
handler.Should().BeAssignableTo<IAsyncDisposable>();
await handler.DisposeAsync();
}
#endregion
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Workflows.Declarative.Mcp\Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
</ItemGroup>
</Project>
@@ -459,4 +459,208 @@ public sealed class JsonDocumentExtensionsTests
Assert.Equal("Bob", second["name"]);
Assert.Equal("Designer", second["role"]);
}
[Fact]
public void GetListTypeFromJson_EmptyArray_ReturnsFallbackListType()
{
// Arrange
JsonDocument document = JsonDocument.Parse("[]");
// Act
VariableType result = document.RootElement.GetListTypeFromJson();
// Assert
Assert.Equal(VariableType.ListType, result.Type);
Assert.False(result.HasSchema);
}
[Fact]
public void GetListTypeFromJson_ArrayOfPrimitives_ReturnsFallbackListType()
{
// Arrange
JsonDocument document = JsonDocument.Parse("[1, 2, 3]");
// Act
VariableType result = document.RootElement.GetListTypeFromJson();
// Assert
Assert.Equal(VariableType.ListType, result.Type);
Assert.False(result.HasSchema);
}
[Fact]
public void GetListTypeFromJson_ObjectWithStringField_InfersStringType()
{
// Arrange
JsonDocument document = JsonDocument.Parse(
"""
[{ "name": "hello" }]
""");
// Act
VariableType result = document.RootElement.GetListTypeFromJson();
// Assert
Assert.True(result.HasSchema);
Assert.True(result.Schema!.ContainsKey("name"));
Assert.Equal(typeof(string), result.Schema["name"].Type);
}
[Fact]
public void GetListTypeFromJson_ObjectWithNumberField_InfersDecimalType()
{
// Arrange
JsonDocument document = JsonDocument.Parse(
"""
[{ "value": 42 }]
""");
// Act
VariableType result = document.RootElement.GetListTypeFromJson();
// Assert
Assert.True(result.HasSchema);
Assert.True(result.Schema!.ContainsKey("value"));
Assert.Equal(typeof(decimal), result.Schema["value"].Type);
}
[Fact]
public void GetListTypeFromJson_ObjectWithBooleanTrueField_InfersBoolType()
{
// Arrange
JsonDocument document = JsonDocument.Parse(
"""
[{ "flag": true }]
""");
// Act
VariableType result = document.RootElement.GetListTypeFromJson();
// Assert
Assert.True(result.HasSchema);
Assert.True(result.Schema!.ContainsKey("flag"));
Assert.Equal(typeof(bool), result.Schema["flag"].Type);
}
[Fact]
public void GetListTypeFromJson_ObjectWithBooleanFalseField_InfersBoolType()
{
// Arrange
JsonDocument document = JsonDocument.Parse(
"""
[{ "flag": false }]
""");
// Act
VariableType result = document.RootElement.GetListTypeFromJson();
// Assert
Assert.True(result.HasSchema);
Assert.True(result.Schema!.ContainsKey("flag"));
Assert.Equal(typeof(bool), result.Schema["flag"].Type);
}
[Fact]
public void GetListTypeFromJson_ObjectWithNestedObjectField_InfersRecordType()
{
// Arrange
JsonDocument document = JsonDocument.Parse(
"""
[{ "child": { "inner": 1 } }]
""");
// Act
VariableType result = document.RootElement.GetListTypeFromJson();
// Assert
Assert.True(result.HasSchema);
Assert.True(result.Schema!.ContainsKey("child"));
Assert.Equal(VariableType.RecordType, result.Schema["child"].Type);
}
[Fact]
public void GetListTypeFromJson_ObjectWithNestedArrayField_InfersListType()
{
// Arrange
JsonDocument document = JsonDocument.Parse(
"""
[{ "items": [1, 2, 3] }]
""");
// Act
VariableType result = document.RootElement.GetListTypeFromJson();
// Assert
Assert.True(result.HasSchema);
Assert.True(result.Schema!.ContainsKey("items"));
Assert.Equal(VariableType.ListType, result.Schema["items"].Type);
}
[Fact]
public void GetListTypeFromJson_ObjectWithNullField_InfersStringTypeDefault()
{
// Arrange
JsonDocument document = JsonDocument.Parse(
"""
[{ "missing": null }]
""");
// Act
VariableType result = document.RootElement.GetListTypeFromJson();
// Assert
Assert.True(result.HasSchema);
Assert.True(result.Schema!.ContainsKey("missing"));
Assert.Equal(typeof(string), result.Schema["missing"].Type);
}
[Fact]
public void GetListTypeFromJson_SkipsNonObjectElements_InfersFromFirstObject()
{
// Arrange
JsonDocument document = JsonDocument.Parse(
"""
[1, "text", { "id": 99 }]
""");
// Act
VariableType result = document.RootElement.GetListTypeFromJson();
// Assert
Assert.True(result.HasSchema);
Assert.True(result.Schema!.ContainsKey("id"));
Assert.Equal(typeof(decimal), result.Schema["id"].Type);
}
[Fact]
public void GetListTypeFromJson_ObjectWithAllFieldTypes_InfersCorrectTypes()
{
// Arrange
JsonDocument document = JsonDocument.Parse(
"""
[{
"text": "hello",
"count": 5,
"enabled": true,
"disabled": false,
"nested": { "x": 1 },
"list": [1, 2],
"empty": null
}]
""");
// Act
VariableType result = document.RootElement.GetListTypeFromJson();
// Assert
Assert.True(result.HasSchema);
Assert.Equal(7, result.Schema!.Count);
Assert.Equal(typeof(string), result.Schema["text"].Type);
Assert.Equal(typeof(decimal), result.Schema["count"].Type);
Assert.Equal(typeof(bool), result.Schema["enabled"].Type);
Assert.Equal(typeof(bool), result.Schema["disabled"].Type);
Assert.Equal(VariableType.RecordType, result.Schema["nested"].Type);
Assert.Equal(VariableType.ListType, result.Schema["list"].Type);
Assert.Equal(typeof(string), result.Schema["empty"].Type);
}
}
@@ -0,0 +1,845 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
using Moq;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="InvokeMcpToolExecutor"/>.
/// </summary>
public sealed class InvokeMcpToolExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
private const string TestServerUrl = "https://mcp.example.com";
private const string TestServerLabel = "TestMcpServer";
private const string TestToolName = "test_tool";
#region Step Naming Convention Tests
[Fact]
public void InvokeMcpToolThrowsWhenModelInvalid()
{
// Arrange
Mock<IMcpToolHandler> mockProvider = new();
MockAgentProvider mockAgentProvider = new();
// Act & Assert
Assert.Throws<DeclarativeModelException>(() => new InvokeMcpToolExecutor(
new InvokeMcpTool(),
mockProvider.Object,
mockAgentProvider.Object,
this.State));
}
[Fact]
public void InvokeMcpToolNamingConvention()
{
// Arrange
string testId = this.CreateActionId().Value;
// Act
string externalInputStep = InvokeMcpToolExecutor.Steps.ExternalInput(testId);
string resumeStep = InvokeMcpToolExecutor.Steps.Resume(testId);
// Assert
Assert.Equal($"{testId}_{nameof(InvokeMcpToolExecutor.Steps.ExternalInput)}", externalInputStep);
Assert.Equal($"{testId}_{nameof(InvokeMcpToolExecutor.Steps.Resume)}", resumeStep);
}
#endregion
#region RequiresInput and RequiresNothing Tests
[Fact]
public void RequiresInputReturnsTrueForExternalInputRequest()
{
// Arrange
ExternalInputRequest request = new(new AgentResponse([]));
// Act
bool result = InvokeMcpToolExecutor.RequiresInput(request);
// Assert
Assert.True(result);
}
[Fact]
public void RequiresInputReturnsFalseForOtherTypes()
{
// Act & Assert
Assert.False(InvokeMcpToolExecutor.RequiresInput(null));
Assert.False(InvokeMcpToolExecutor.RequiresInput("string"));
Assert.False(InvokeMcpToolExecutor.RequiresInput(new ActionExecutorResult("test")));
}
[Fact]
public void RequiresNothingReturnsTrueForActionExecutorResult()
{
// Arrange
ActionExecutorResult result = new("test");
// Act
bool requiresNothing = InvokeMcpToolExecutor.RequiresNothing(result);
// Assert
Assert.True(requiresNothing);
}
[Fact]
public void RequiresNothingReturnsFalseForOtherTypes()
{
// Act & Assert
Assert.False(InvokeMcpToolExecutor.RequiresNothing(null));
Assert.False(InvokeMcpToolExecutor.RequiresNothing("string"));
Assert.False(InvokeMcpToolExecutor.RequiresNothing(new ExternalInputRequest(new AgentResponse([]))));
}
#endregion
#region ExecuteAsync Tests
[Fact]
public async Task InvokeMcpToolExecuteWithoutApprovalAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolExecuteWithoutApprovalAsync),
serverUrl: TestServerUrl,
toolName: TestToolName,
requireApproval: false);
// Act and Assert
await this.ExecuteTestAsync(model);
}
[Fact]
public async Task InvokeMcpToolExecuteWithServerLabelAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolExecuteWithServerLabelAsync),
serverUrl: TestServerUrl,
serverLabel: TestServerLabel,
toolName: TestToolName);
// Act and Assert
await this.ExecuteTestAsync(model);
}
[Fact]
public async Task InvokeMcpToolExecuteWithArgumentsAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolExecuteWithArgumentsAsync),
serverUrl: TestServerUrl,
toolName: TestToolName,
argumentKey: "query",
argumentValue: "test query");
// Act and Assert
await this.ExecuteTestAsync(model);
}
[Fact]
public async Task InvokeMcpToolExecuteWithHeadersAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolExecuteWithHeadersAsync),
serverUrl: TestServerUrl,
toolName: TestToolName,
headerKey: "Authorization",
headerValue: "Bearer token123");
// Act and Assert
await this.ExecuteTestAsync(model);
}
[Fact]
public async Task InvokeMcpToolExecuteWithRequireApprovalAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolExecuteWithRequireApprovalAsync),
serverUrl: TestServerUrl,
toolName: TestToolName,
requireApproval: true);
// Act and Assert
await this.ExecuteTestAsync(model);
}
[Fact]
public async Task InvokeMcpToolExecuteWithEmptyConversationIdAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolExecuteWithEmptyConversationIdAsync),
serverUrl: TestServerUrl,
toolName: TestToolName,
conversationId: "");
// Act and Assert
await this.ExecuteTestAsync(model);
}
[Fact]
public async Task InvokeMcpToolExecuteWithNullArgumentsAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolExecuteWithNullArgumentsAsync),
serverUrl: TestServerUrl,
toolName: TestToolName,
argumentKey: null);
// Act and Assert
await this.ExecuteTestAsync(model);
}
[Fact]
public async Task InvokeMcpToolExecuteWithNullRequireApprovalAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolExecuteWithNullRequireApprovalAsync),
serverUrl: TestServerUrl,
toolName: TestToolName,
requireApproval: null);
// Act and Assert
await this.ExecuteTestAsync(model);
}
[Fact]
public async Task InvokeMcpToolExecuteWithNullConversationIdAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolExecuteWithNullConversationIdAsync),
serverUrl: TestServerUrl,
toolName: TestToolName,
conversationId: null);
// Act and Assert
await this.ExecuteTestAsync(model);
}
[Fact]
public async Task InvokeMcpToolExecuteWithEmptyServerLabelAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolExecuteWithEmptyServerLabelAsync),
serverUrl: TestServerUrl,
serverLabel: "",
toolName: TestToolName);
// Act and Assert
await this.ExecuteTestAsync(model);
}
[Fact]
public async Task InvokeMcpToolExecuteWithConversationIdAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolExecuteWithConversationIdAsync),
serverUrl: TestServerUrl,
toolName: TestToolName,
conversationId: "test-conversation-id");
// Act and Assert
await this.ExecuteTestAsync(model);
}
[Fact]
public async Task InvokeMcpToolExecuteWithRequireApprovalAndHeadersAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolExecuteWithRequireApprovalAndHeadersAsync),
serverUrl: TestServerUrl,
toolName: TestToolName,
requireApproval: true,
headerKey: "X-Custom-Header",
headerValue: "custom-value");
// Act and Assert
await this.ExecuteTestAsync(model);
}
[Fact]
public async Task InvokeMcpToolExecuteWithEmptyHeaderValueAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolExecuteWithEmptyHeaderValueAsync),
serverUrl: TestServerUrl,
toolName: TestToolName,
headerKey: "X-Empty-Header",
headerValue: "");
// Act and Assert
await this.ExecuteTestAsync(model);
}
[Fact]
public async Task InvokeMcpToolExecuteWithJsonObjectResultAsync()
{
// Arrange - Tests JSON object parsing in AssignResultAsync
this.State.InitializeSystem();
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolExecuteWithJsonObjectResultAsync),
serverUrl: TestServerUrl,
toolName: TestToolName);
MockMcpToolProvider mockProvider = new(returnJsonObject: true);
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);
}
[Fact]
public async Task InvokeMcpToolExecuteWithJsonArrayResultAsync()
{
// Arrange - Tests JSON array parsing in AssignResultAsync
this.State.InitializeSystem();
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolExecuteWithJsonArrayResultAsync),
serverUrl: TestServerUrl,
toolName: TestToolName);
MockMcpToolProvider mockProvider = new(returnJsonArray: true);
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);
}
[Fact]
public async Task InvokeMcpToolExecuteWithInvalidJsonResultAsync()
{
// Arrange - Tests graceful handling of invalid JSON
this.State.InitializeSystem();
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolExecuteWithInvalidJsonResultAsync),
serverUrl: TestServerUrl,
toolName: TestToolName);
MockMcpToolProvider mockProvider = new(returnInvalidJson: true);
MockAgentProvider mockAgentProvider = new();
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
// Act
WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false);
// Assert - Should handle gracefully
VerifyModel(model, action);
VerifyInvocationEvent(events);
}
[Fact]
public async Task InvokeMcpToolExecuteWithDataContentResultAsync()
{
// Arrange - Tests DataContent handling (returns URI)
this.State.InitializeSystem();
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolExecuteWithDataContentResultAsync),
serverUrl: TestServerUrl,
toolName: TestToolName);
MockMcpToolProvider mockProvider = new(returnDataContent: true);
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);
}
[Fact]
public async Task InvokeMcpToolExecuteWithEmptyOutputAsync()
{
// Arrange - Tests empty output list handling
this.State.InitializeSystem();
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolExecuteWithEmptyOutputAsync),
serverUrl: TestServerUrl,
toolName: TestToolName);
MockMcpToolProvider mockProvider = new(returnEmptyOutput: true);
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);
}
[Fact]
public async Task InvokeMcpToolExecuteWithNullOutputAsync()
{
// Arrange - Tests null output handling
this.State.InitializeSystem();
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolExecuteWithNullOutputAsync),
serverUrl: TestServerUrl,
toolName: TestToolName);
MockMcpToolProvider mockProvider = new(returnNullOutput: true);
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);
}
[Fact]
public async Task InvokeMcpToolExecuteWithMultipleContentTypesAsync()
{
// Arrange - Tests handling of multiple content types in output
this.State.InitializeSystem();
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolExecuteWithMultipleContentTypesAsync),
serverUrl: TestServerUrl,
toolName: TestToolName);
MockMcpToolProvider mockProvider = new(returnMultipleContent: true);
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);
}
#endregion
#region CaptureResponseAsync Tests
[Fact]
public async Task InvokeMcpToolCaptureResponseWithApprovalApprovedAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolCaptureResponseWithApprovalApprovedAsync),
serverUrl: TestServerUrl,
toolName: TestToolName,
requireApproval: true);
MockMcpToolProvider mockProvider = new();
MockAgentProvider mockAgentProvider = new();
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
// Create approval request then response
McpServerToolCallContent toolCall = new(action.Id, TestToolName, TestServerUrl);
McpServerToolApprovalRequestContent approvalRequest = new(action.Id, toolCall);
McpServerToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true);
ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse]));
// Act
WorkflowEvent[] events = await this.ExecuteCaptureResponseTestAsync(action, response);
// Assert
VerifyModel(model, action);
Assert.NotEmpty(events);
}
[Fact]
public async Task InvokeMcpToolCaptureResponseWithApprovalRejectedAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolCaptureResponseWithApprovalRejectedAsync),
serverUrl: TestServerUrl,
toolName: TestToolName,
requireApproval: true);
MockMcpToolProvider mockProvider = new();
MockAgentProvider mockAgentProvider = new();
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
// Create approval request then response (rejected)
McpServerToolCallContent toolCall = new(action.Id, TestToolName, TestServerUrl);
McpServerToolApprovalRequestContent approvalRequest = new(action.Id, toolCall);
McpServerToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: false);
ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse]));
// Act
WorkflowEvent[] events = await this.ExecuteCaptureResponseTestAsync(action, response);
// Assert
VerifyModel(model, action);
Assert.NotEmpty(events);
}
[Fact]
public async Task InvokeMcpToolCaptureResponseWithEmptyMessagesAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolCaptureResponseWithEmptyMessagesAsync),
serverUrl: TestServerUrl,
toolName: TestToolName);
MockMcpToolProvider mockProvider = new();
MockAgentProvider mockAgentProvider = new();
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
// Empty response - no approval found, should treat as rejected
ExternalInputResponse response = new([]);
// Act
WorkflowEvent[] events = await this.ExecuteCaptureResponseTestAsync(action, response);
// Assert
VerifyModel(model, action);
Assert.NotEmpty(events);
}
[Fact]
public async Task InvokeMcpToolCaptureResponseWithNonMatchingApprovalIdAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolCaptureResponseWithNonMatchingApprovalIdAsync),
serverUrl: TestServerUrl,
toolName: TestToolName);
MockMcpToolProvider mockProvider = new();
MockAgentProvider mockAgentProvider = new();
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
// Create approval with different ID
McpServerToolCallContent toolCall = new("different_id", TestToolName, TestServerUrl);
McpServerToolApprovalRequestContent approvalRequest = new("different_id", toolCall);
McpServerToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true);
ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse]));
// Act
WorkflowEvent[] events = await this.ExecuteCaptureResponseTestAsync(action, response);
// Assert - Should be treated as rejected since no matching approval
VerifyModel(model, action);
Assert.NotEmpty(events);
}
[Fact]
public async Task InvokeMcpToolCaptureResponseWithApprovedAndArgumentsAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolCaptureResponseWithApprovedAndArgumentsAsync),
serverUrl: TestServerUrl,
toolName: TestToolName,
requireApproval: true,
argumentKey: "query",
argumentValue: "test query");
MockMcpToolProvider mockProvider = new();
MockAgentProvider mockAgentProvider = new();
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
// Create approval request then response
McpServerToolCallContent toolCall = new(action.Id, TestToolName, TestServerUrl);
McpServerToolApprovalRequestContent approvalRequest = new(action.Id, toolCall);
McpServerToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true);
ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse]));
// Act
WorkflowEvent[] events = await this.ExecuteCaptureResponseTestAsync(action, response);
// Assert
VerifyModel(model, action);
Assert.NotEmpty(events);
}
[Fact]
public async Task InvokeMcpToolCaptureResponseWithApprovedAndHeadersAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolCaptureResponseWithApprovedAndHeadersAsync),
serverUrl: TestServerUrl,
serverLabel: TestServerLabel,
toolName: TestToolName,
requireApproval: true,
headerKey: "X-Custom-Header",
headerValue: "custom-value");
MockMcpToolProvider mockProvider = new();
MockAgentProvider mockAgentProvider = new();
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
// Create approval request then response
McpServerToolCallContent toolCall = new(action.Id, TestToolName, TestServerLabel);
McpServerToolApprovalRequestContent approvalRequest = new(action.Id, toolCall);
McpServerToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true);
ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse]));
// Act
WorkflowEvent[] events = await this.ExecuteCaptureResponseTestAsync(action, response);
// Assert
VerifyModel(model, action);
Assert.NotEmpty(events);
}
[Fact]
public async Task InvokeMcpToolCaptureResponseWithApprovedAndConversationIdAsync()
{
// Arrange
this.State.InitializeSystem();
const string ConversationId = "TestConversationId";
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolCaptureResponseWithApprovedAndConversationIdAsync),
serverUrl: TestServerUrl,
toolName: TestToolName,
requireApproval: true,
conversationId: ConversationId);
MockMcpToolProvider mockProvider = new();
MockAgentProvider mockAgentProvider = new();
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
// Create approval request then response
McpServerToolCallContent toolCall = new(action.Id, TestToolName, TestServerUrl);
McpServerToolApprovalRequestContent approvalRequest = new(action.Id, toolCall);
McpServerToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true);
ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse]));
// Act
WorkflowEvent[] events = await this.ExecuteCaptureResponseTestAsync(action, response);
// Assert
VerifyModel(model, action);
Assert.NotEmpty(events);
}
#endregion
#region CompleteAsync Tests
[Fact]
public async Task InvokeMcpToolCompleteAsyncRaisesCompletionEventAsync()
{
// Arrange
this.State.InitializeSystem();
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolCompleteAsyncRaisesCompletionEventAsync),
serverUrl: TestServerUrl,
toolName: TestToolName);
MockMcpToolProvider mockProvider = new();
MockAgentProvider mockAgentProvider = new();
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
ActionExecutorResult result = new(action.Id);
// Act
WorkflowEvent[] events = await this.ExecuteCompleteTestAsync(action, result);
// Assert
VerifyModel(model, action);
Assert.NotEmpty(events);
}
#endregion
#region Helper Methods
private async Task ExecuteTestAsync(InvokeMcpTool model)
{
MockMcpToolProvider mockProvider = new();
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);
// IsDiscreteAction should be false for InvokeMcpTool
VerifyIsDiscrete(action, isDiscrete: false);
}
private async Task<WorkflowEvent[]> ExecuteCaptureResponseTestAsync(
InvokeMcpToolExecutor action,
ExternalInputResponse response)
{
return await this.ExecuteAsync(
action,
InvokeMcpToolExecutor.Steps.ExternalInput(action.Id),
(context, _, cancellationToken) => action.CaptureResponseAsync(context, response, cancellationToken));
}
private async Task<WorkflowEvent[]> ExecuteCompleteTestAsync(
InvokeMcpToolExecutor action,
ActionExecutorResult result)
{
return await this.ExecuteAsync(
action,
InvokeMcpToolExecutor.Steps.Resume(action.Id),
(context, _, cancellationToken) => action.CompleteAsync(context, result, cancellationToken));
}
private InvokeMcpTool CreateModel(
string displayName,
string serverUrl,
string toolName,
string? serverLabel = null,
bool? requireApproval = false,
string? conversationId = null,
string? argumentKey = null,
string? argumentValue = null,
string? headerKey = null,
string? headerValue = null)
{
InvokeMcpTool.Builder builder = new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
ServerUrl = new StringExpression.Builder(StringExpression.Literal(serverUrl)),
ToolName = new StringExpression.Builder(StringExpression.Literal(toolName)),
RequireApproval = requireApproval != null ? new BoolExpression.Builder(BoolExpression.Literal(requireApproval.Value)) : null
};
if (serverLabel is not null)
{
builder.ServerLabel = new StringExpression.Builder(StringExpression.Literal(serverLabel));
}
if (conversationId is not null)
{
builder.ConversationId = new StringExpression.Builder(StringExpression.Literal(conversationId));
}
if (argumentKey is not null && argumentValue is not null)
{
builder.Arguments.Add(argumentKey, ValueExpression.Literal(new StringDataValue(argumentValue)));
}
if (headerKey is not null && headerValue is not null)
{
builder.Headers.Add(headerKey, new StringExpression.Builder(StringExpression.Literal(headerValue)));
}
return AssignParent<InvokeMcpTool>(builder);
}
#endregion
#region Mock MCP Tool Provider
/// <summary>
/// Mock implementation of <see cref="IMcpToolHandler"/> for unit testing purposes.
/// </summary>
private sealed class MockMcpToolProvider : Mock<IMcpToolHandler>
{
public MockMcpToolProvider(
bool returnJsonObject = false,
bool returnJsonArray = false,
bool returnInvalidJson = false,
bool returnDataContent = false,
bool returnEmptyOutput = false,
bool returnNullOutput = false,
bool returnMultipleContent = false)
{
this.Setup(provider => provider.InvokeToolAsync(
It.IsAny<string>(),
It.IsAny<string?>(),
It.IsAny<string>(),
It.IsAny<IDictionary<string, object?>?>(),
It.IsAny<IDictionary<string, string>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>()))
.Returns<string, string?, string, IDictionary<string, object?>?, IDictionary<string, string>?, string?, CancellationToken>(
(_, _, _, _, _, _, _) =>
{
McpServerToolResultContent result = new("mock-call-id");
if (returnNullOutput)
{
result.Output = null;
}
else if (returnEmptyOutput)
{
result.Output = [];
}
else if (returnJsonObject)
{
result.Output = [new TextContent("{\"key\": \"value\", \"number\": 42}")];
}
else if (returnJsonArray)
{
result.Output = [new TextContent("[1, 2, 3, \"four\"]")];
}
else if (returnInvalidJson)
{
result.Output = [new TextContent("this is not valid json {")];
}
else if (returnDataContent)
{
result.Output = [new DataContent("data:image/png;base64,iVBORw0KGgo=", "image/png")];
}
else if (returnMultipleContent)
{
result.Output =
[
new TextContent("First text"),
new TextContent("{\"nested\": true}"),
new DataContent("data:audio/mp3;base64,SUQz", "audio/mp3")
];
}
else
{
result.Output = [new TextContent("Mock MCP tool result")];
}
return Task.FromResult(result);
});
}
}
#endregion
}