diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 0de4409d53..396a316575 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -111,9 +111,9 @@ - - - + + + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 041da4a6f6..10e2ae32bf 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -227,6 +227,7 @@ + @@ -438,6 +439,7 @@ + @@ -483,6 +485,7 @@ + diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj new file mode 100644 index 0000000000..9c905deeb7 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj @@ -0,0 +1,39 @@ + + + + Exe + net10.0 + enable + enable + + + + true + true + true + true + + + + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.yaml b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.yaml new file mode 100644 index 0000000000..7b942cb2bd --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.yaml @@ -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 diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/Program.cs new file mode 100644 index 0000000000..3afebc560b --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/Program.cs @@ -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; + +/// +/// Demonstrates a workflow that uses InvokeMcpTool to call MCP server tools +/// directly from the workflow. +/// +/// +/// +/// The InvokeMcpTool action allows workflows to invoke tools on MCP (Model Context Protocol) +/// servers. This enables: +/// +/// +/// Searching external data sources like documentation +/// Executing operations on remote servers +/// Integrating with MCP-compatible services +/// +/// +/// 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. +/// +/// +/// See the README.md file in the parent folder (../README.md) for detailed +/// information about the configuration required to run this sample. +/// +/// +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 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. + """ + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs new file mode 100644 index 0000000000..751f518277 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs @@ -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; + +/// +/// Default implementation of using the MCP C# SDK. +/// +/// +/// This provider supports per-server authentication via the httpClientProvider callback. +/// The callback allows different MCP servers to use different authentication configurations by returning +/// a pre-configured for each server. +/// +public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable +{ + private readonly Func>? _httpClientProvider; + private readonly Dictionary _clients = []; + private readonly Dictionary _ownedHttpClients = []; + private readonly SemaphoreSlim _clientLock = new(1, 1); + + /// + /// Initializes a new instance of the class. + /// + /// + /// An optional callback that provides an for each MCP server. + /// The callback receives (serverUrl, cancellationToken) and should return an HttpClient + /// configured with any required authentication. Return to use a default HttpClient with no auth. + /// + public DefaultMcpToolHandler(Func>? httpClientProvider = null) + { + this._httpClientProvider = httpClientProvider; + } + + /// + public async Task InvokeToolAsync( + string serverUrl, + string? serverLabel, + string toolName, + IDictionary? arguments, + IDictionary? 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? readOnlyArguments = arguments is null + ? null + : arguments as IReadOnlyDictionary ?? new Dictionary(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; + } + + /// + 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 GetOrCreateClientAsync( + string serverUrl, + string? serverLabel, + IDictionary? 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 CreateClientAsync( + string serverUrl, + string? serverLabel, + IDictionary? 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? 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 sorted = new(headers.ToDictionary(h => h.Key.ToUpperInvariant(), h => h.Value.ToUpperInvariant())); + int hashCode = 17; + foreach (KeyValuePair 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); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj new file mode 100644 index 0000000000..f9bf706669 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj @@ -0,0 +1,33 @@ + + + + true + $(NoWarn);MEAI001;OPENAI001 + + + + true + true + true + + + + + + + Microsoft Agent Framework Declarative Workflows MCP + Provides Microsoft Agent Framework support for MCP (Model Context Protocol) server integration in declarative workflows. + + + + + + + + + + + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs index c4808f9311..9e421832d4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs @@ -20,6 +20,12 @@ public sealed class DeclarativeWorkflowOptions(ResponseAgentProvider agentProvid /// public ResponseAgentProvider AgentProvider { get; } = Throw.IfNull(agentProvider); + /// + /// 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. + /// + public IMcpToolHandler? McpToolHandler { get; init; } + /// /// Defines the configuration settings for the workflow. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/JsonDocumentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/JsonDocumentExtensions.cs index e2b038bbd7..a74b9c6eb8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/JsonDocumentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/JsonDocumentExtensions.cs @@ -42,6 +42,40 @@ internal static class JsonDocumentExtensions }; } + /// + /// Creates a VariableType.List with schema inferred from the first object element in the array. + /// + 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 ParseRecord(this JsonElement currentElement, VariableType targetType) { IEnumerable> keyValuePairs = @@ -118,6 +152,7 @@ VariableType DetermineElementType() 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 @@ private static bool TryParseObject(JsonElement propertyElement, VariableType? ta 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(); + return true; + } + try { - value = ParseTable(propertyElement, targetType ?? VariableType.ListType); + value = ParseTable(propertyElement, targetType ?? GetListTypeFromJson(propertyElement)); return true; } catch diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IMcpToolHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IMcpToolHandler.cs new file mode 100644 index 0000000000..56b1c3deb4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IMcpToolHandler.cs @@ -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; + +/// +/// Defines the contract for invoking MCP tools within declarative workflows. +/// +/// +/// This interface allows the MCP tool invocation to be abstracted, enabling +/// different implementations for local development, hosted workflows, and testing scenarios. +/// +public interface IMcpToolHandler +{ + /// + /// Invokes an MCP tool on the specified server. + /// + /// The URL of the MCP server. + /// An optional label identifying the server connection. + /// The name of the tool to invoke. + /// Optional arguments to pass to the tool. + /// Optional headers to include in the request. + /// An optional connection name for managed connections. + /// A token to observe cancellation. + /// + /// A task representing the asynchronous operation. The result contains a + /// with the tool invocation output. + /// + Task InvokeToolAsync( + string serverUrl, + string? serverLabel, + string toolName, + IDictionary? arguments, + IDictionary? headers, + string? connectionName, + CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 7b84e24839..fd818672dd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -493,6 +493,42 @@ protected override void Visit(SendActivity item) 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(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(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); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowTemplateVisitor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowTemplateVisitor.cs index 568a38950c..f754c45c62 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowTemplateVisitor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowTemplateVisitor.cs @@ -365,6 +365,8 @@ protected override void Visit(SendActivity item) #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); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs index a5215c283b..0e95bebe63 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs @@ -204,7 +204,7 @@ private async ValueTask AssignResultAsync(IWorkflowContext context, FunctionResu 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 @@ private async ValueTask AssignResultAsync(IWorkflowContext context, FunctionResu await this.AssignAsync(this.Model.Output.Result?.Path, resultValue.ToFormula(), context).ConfigureAwait(false); } - /// - /// Creates a VariableType.List with schema inferred from the first object element in the array. - /// - 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( diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs new file mode 100644 index 0000000000..45929f20f7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs @@ -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; + +/// +/// Executor for the action. +/// This executor invokes MCP tools on remote servers and handles approval flows. +/// +internal sealed class InvokeMcpToolExecutor( + InvokeMcpTool model, + IMcpToolHandler mcpToolHandler, + ResponseAgentProvider agentProvider, + WorkflowFormulaState state) : + DeclarativeActionExecutor(model, state) +{ + /// + /// Step identifiers for the MCP tool invocation workflow. + /// + public static class Steps + { + /// + /// Step for waiting for external input (approval or direct response). + /// + public static string ExternalInput(string id) => $"{id}_{nameof(ExternalInput)}"; + + /// + /// Step for resuming after receiving external input. + /// + public static string Resume(string id) => $"{id}_{nameof(Resume)}"; + } + + /// + /// Determines if the message indicates external input is required. + /// + public static bool RequiresInput(object? message) => message is ExternalInputRequest; + + /// + /// Determines if the message indicates no external input is required. + /// + public static bool RequiresNothing(object? message) => message is ActionExecutorResult; + + /// + protected override bool EmitResultEvent => false; + + /// + protected override bool IsDiscreteAction => false; + + /// + [SendsMessage(typeof(ExternalInputRequest))] + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + string serverUrl = this.GetServerUrl(); + string? serverLabel = this.GetServerLabel(); + string toolName = this.GetToolName(); + bool requireApproval = this.GetRequireApproval(); + Dictionary? arguments = this.GetArguments(); + Dictionary? 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; + } + + /// + /// Captures the external input response and processes the MCP tool result. + /// + /// The workflow context. + /// The external input response. + /// A cancellation token. + /// A representing the asynchronous operation. + public async ValueTask CaptureResponseAsync( + IWorkflowContext context, + ExternalInputResponse response, + CancellationToken cancellationToken) + { + // Check for approval response + McpServerToolApprovalResponseContent? approvalResponse = response.Messages + .SelectMany(m => m.Contents) + .OfType() + .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? arguments = this.GetArguments(); + Dictionary? 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); + } + + /// + /// Completes the MCP tool invocation by raising the completion event. + /// + 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 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? GetArguments() + { + if (this.Model.Arguments is null) + { + return null; + } + + Dictionary result = []; + foreach (KeyValuePair argument in this.Model.Arguments) + { + result[argument.Key] = this.Evaluator.GetValue(argument.Value).Value.ToObject(); + } + + return result; + } + + private Dictionary? GetHeaders() + { + if (this.Model.Headers is null) + { + return null; + } + + Dictionary result = []; + foreach (KeyValuePair header in this.Model.Headers) + { + string value = this.Evaluator.GetValue(header.Value).Value; + if (!string.IsNullOrEmpty(value)) + { + result[header.Key] = value; + } + } + + return result; + } +} diff --git a/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs b/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs index 1f1570312a..a36c388e73 100644 --- a/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs +++ b/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs @@ -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; } + /// /// Create the workflow from the declarative YAML. Includes definition of the /// and the associated . @@ -42,6 +45,7 @@ public Workflow CreateWorkflow() Configuration = this.Configuration, ConversationId = this.ConversationId, LoggerFactory = this.LoggerFactory, + McpToolHandler = this.McpToolHandler, }; string workflowPath = Path.Combine(AppContext.BaseDirectory, workflowFile); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs index 662575cdf4..c7583acf0a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs @@ -61,6 +61,11 @@ protected static void SetProduct() internal static string FormatVariablePath(string variableName, string? scope = null) => $"{scope ?? WorkflowFormulaState.DefaultScopeName}.{variableName}"; protected async ValueTask CreateOptionsAsync(bool externalConversation = false, params IEnumerable functionTools) + { + return await this.CreateOptionsAsync(externalConversation, mcpToolProvider: null, functionTools).ConfigureAwait(false); + } + + protected async ValueTask CreateOptionsAsync(bool externalConversation, IMcpToolHandler? mcpToolProvider, params IEnumerable functionTools) { AzureAgentProvider agentProvider = new(this.TestEndpoint, new AzureCliCredential()) @@ -78,7 +83,8 @@ protected async ValueTask CreateOptionsAsync(bool ex new DeclarativeWorkflowOptions(agentProvider) { ConversationId = conversationId, - LoggerFactory = this.Output + LoggerFactory = this.Output, + McpToolHandler = mcpToolProvider }; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeFunctionToolWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs similarity index 51% rename from dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeFunctionToolWorkflowTest.cs rename to dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs index 289fbe2faa..359d9389a6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeFunctionToolWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs @@ -10,31 +10,48 @@ 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; /// -/// Integration tests for InvokeFunctionTool action. -/// This test pattern can be extended for other InvokeTool types. +/// Integration tests for InvokeFunctionTool and InvokeMcpTool actions. /// -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 /// - /// 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. /// - /// The workflow YAML file name. - /// Expected function names to be called in order. - /// Expected text to be present in the final result. - private async Task RunInvokeToolTestAsync( + private async Task RunInvokeFunctionToolTestAsync( string workflowFileName, string[] expectedFunctionCalls, string? expectedResultContains = null) @@ -72,7 +89,6 @@ private async Task RunInvokeToolTestAsync( // 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 @@ private async Task RunInvokeToolTestAsync( } // 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() - .LastOrDefault(); - - Assert.NotNull(messageEvent); - Assert.Contains(expectedResultContains, messageEvent.Message, StringComparison.OrdinalIgnoreCase); + AssertResultContains(workflowEvents, expectedResultContains); } } @@ -150,6 +159,119 @@ private async Task> ProcessFunctionCallsAsync( return results; } + #endregion + + #region InvokeMcpTool Test Helpers + + /// + /// Runs an InvokeMcpTool workflow test with the specified configuration. + /// + 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(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(); + Assert.NotNull(toolRequest); + + IList 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); + } + + /// + /// Processes MCP tool requests from an external input request. + /// Handles approval requests for MCP tools. + /// + private List ProcessMcpToolRequests( + ExternalInputRequest toolRequest, + bool approveRequest) + { + List results = []; + + foreach (ChatMessage message in toolRequest.AgentResponse.Messages) + { + // Handle MCP approval requests if present + foreach (McpServerToolApprovalRequestContent approvalRequest in message.Contents.OfType()) + { + 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() + .LastOrDefault(); + + Assert.NotNull(messageEvent); + Assert.Contains(expectedResultContains, messageEvent.Message, StringComparison.OrdinalIgnoreCase); + } + private static string GetWorkflowPath(string workflowFileName) => Path.Combine(Environment.CurrentDirectory, "Workflows", workflowFileName); + + #endregion } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj index 309a590b83..9bc867b139 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj @@ -10,6 +10,7 @@ + diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeMcpTool.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeMcpTool.yaml new file mode 100644 index 0000000000..ff30f7902e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeMcpTool.yaml @@ -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}" diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeMcpToolWithApproval.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeMcpToolWithApproval.yaml new file mode 100644 index 0000000000..77d62c79ce --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeMcpToolWithApproval.yaml @@ -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}" diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs new file mode 100644 index 0000000000..858ea9db14 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs @@ -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; + +/// +/// Unit tests for . +/// +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 ProviderAsync(string url, CancellationToken ct) => Task.FromResult(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 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 act = async () => await handler.DisposeAsync(); + + // Assert - Second dispose should throw ObjectDisposedException from the semaphore + await act.Should().ThrowAsync(); + } + + #endregion + + #region HttpClientProvider Tests + + [Fact] + public async Task InvokeToolAsync_WithHttpClientProvider_ShouldCallProviderAsync() + { + // Arrange + bool providerCalled = false; + string? capturedServerUrl = null; + + Task ProviderAsync(string url, CancellationToken ct) + { + providerCalled = true; + capturedServerUrl = url; + return Task.FromResult(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 ProviderAsync(string url, CancellationToken ct) + { + providerCalled = true; + providedClient = new HttpClient(); + return Task.FromResult(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 ProviderAsync(string url, CancellationToken ct) + { + providerCallCount++; + return Task.FromResult(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 ProviderAsync(string url, CancellationToken ct) + { + providerCallCount++; + return Task.FromResult(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 ProviderAsync(string url, CancellationToken ct) + { + providerCallCount++; + return Task.FromResult(null); + } + + DefaultMcpToolHandler handler = new(httpClientProvider: ProviderAsync); + const string ServerUrl = "http://localhost:12345/mcp"; + + try + { + // Act - Call with same URL but different headers + Dictionary[] headerSets = + [ + new() { ["Authorization"] = "Bearer token1" }, + new() { ["Authorization"] = "Bearer token2" } + ]; + + foreach (Dictionary 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(); + await handler.DisposeAsync(); + } + + [Fact] + public async Task DefaultMcpToolHandler_ShouldImplementIAsyncDisposableAsync() + { + // Arrange & Act + DefaultMcpToolHandler handler = new(); + + // Assert + handler.Should().BeAssignableTo(); + await handler.DisposeAsync(); + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests.csproj new file mode 100644 index 0000000000..057e2cd950 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests.csproj @@ -0,0 +1,15 @@ + + + + true + + + + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/JsonDocumentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/JsonDocumentExtensionsTests.cs index fc03d17142..6ace20bebb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/JsonDocumentExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/JsonDocumentExtensionsTests.cs @@ -459,4 +459,208 @@ public void ParseList_ArrayOfObjects_NoSchema_PreservesProperties() 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); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs new file mode 100644 index 0000000000..2cad0029ff --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs @@ -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; + +/// +/// Tests for . +/// +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 mockProvider = new(); + MockAgentProvider mockAgentProvider = new(); + + // Act & Assert + Assert.Throws(() => 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 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 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(builder); + } + + #endregion + + #region Mock MCP Tool Provider + + /// + /// Mock implementation of for unit testing purposes. + /// + private sealed class MockMcpToolProvider : Mock + { + 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(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny(), + It.IsAny())) + .Returns?, IDictionary?, 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 +}