mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
committed by
GitHub
Unverified
parent
2e26bb9387
commit
de9d886aba
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
+39
@@ -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);
|
||||
}
|
||||
}
|
||||
+33
@@ -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>
|
||||
|
||||
+43
-1
@@ -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);
|
||||
}
|
||||
+36
@@ -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);
|
||||
|
||||
+2
@@ -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);
|
||||
|
||||
+1
-35
@@ -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(
|
||||
|
||||
+367
@@ -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);
|
||||
|
||||
+7
-1
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+142
-20
@@ -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
|
||||
}
|
||||
+1
@@ -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>
|
||||
|
||||
+35
@@ -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}"
|
||||
+35
@@ -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}"
|
||||
+345
@@ -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
|
||||
}
|
||||
+15
@@ -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>
|
||||
+204
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+845
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user