From 350c6c391a133bbf91c2b3322d350bc2b71b930b Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Sun, 22 Feb 2026 16:26:57 -0800 Subject: [PATCH 1/8] Initial implementation of InvokeMcpTool in declarative workflow --- dotnet/Directory.Packages.props | 7 +- dotnet/agent-framework-dotnet.slnx | 1 + dotnet/nuget.config | 6 + .../InvokeMcpTool/InvokeMcpTool.csproj | 38 + .../InvokeMcpTool/InvokeMcpTool.yaml | 55 ++ .../Declarative/InvokeMcpTool/Program.cs | 94 ++ .../DeclarativeWorkflowOptions.cs | 6 + .../DefaultMcpToolHandler.cs | 321 +++++++ .../Extensions/JsonDocumentExtensions.cs | 44 +- .../IMcpToolHandler.cs | 41 + .../Interpreter/WorkflowActionVisitor.cs | 36 + .../Interpreter/WorkflowTemplateVisitor.cs | 2 + ...oft.Agents.AI.Workflows.Declarative.csproj | 2 + .../ObjectModel/InvokeFunctionToolExecutor.cs | 36 +- .../ObjectModel/InvokeMcpToolExecutor.cs | 367 ++++++++ .../Workflows/Execution/WorkflowFactory.cs | 4 + .../Framework/IntegrationTest.cs | 8 +- .../InvokeFunctionToolWorkflowTest.cs | 155 ---- .../InvokeToolWorkflowTest.cs | 302 +++++++ .../Workflows/InvokeMcpTool.yaml | 35 + .../Workflows/InvokeMcpToolWithApproval.yaml | 35 + .../Extensions/JsonDocumentExtensionsTests.cs | 204 +++++ .../ObjectModel/InvokeMcpToolExecutorTest.cs | 845 ++++++++++++++++++ 23 files changed, 2449 insertions(+), 195 deletions(-) create mode 100644 dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj create mode 100644 dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.yaml create mode 100644 dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/Program.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultMcpToolHandler.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IMcpToolHandler.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeFunctionToolWorkflowTest.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeMcpTool.yaml create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeMcpToolWithApproval.yaml create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 0de4409d53..e2ae6a98cf 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -23,6 +23,7 @@ + @@ -111,9 +112,9 @@ - - - + + + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 92968ac153..a10b01b2fc 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -216,6 +216,7 @@ + diff --git a/dotnet/nuget.config b/dotnet/nuget.config index 76d943ce16..dfd4634b12 100644 --- a/dotnet/nuget.config +++ b/dotnet/nuget.config @@ -3,10 +3,16 @@ + + + + + + \ No newline at end of file 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..cda2ae1a26 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj @@ -0,0 +1,38 @@ + + + + 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..5b2dd14139 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.yaml @@ -0,0 +1,55 @@ +# +# 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. Sends the search results as an agent message using the McpSearchAgent +# +# MCP Tool Configuration: +# - serverUrl: The MCP server endpoint (https://mcp.ai.azure.com) +# - serverLabel: A label for the server (azure_mcp_server) #optional +# - toolName: The name of the tool to invoke (model_details_get) +# - arguments: Tool arguments (model to search) +# +# 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_mcp_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.SearchResult + + # 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..b2f7faa2c0 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/Program.cs @@ -0,0 +1,94 @@ +// 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.Identity; +using Microsoft.Agents.AI.Workflows.Declarative; +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. +/// +/// +/// 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 provider for invoking MCP server tools + DefaultMcpToolHandler mcpToolProvider = new(tokenCredential: new DefaultAzureCredential(), tokenScopes: ["https://mcp.ai.azure.com"]); + + // Create the workflow factory with MCP tool provider + WorkflowFactory workflowFactory = new("InvokeMcpTool.yaml", foundryEndpoint) + { + McpToolHandler = mcpToolProvider + }; + + // Execute the workflow + WorkflowRunner runner = new() { UseJsonCheckpoints = true }; + await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput); + + // Clean up MCP connections + await mcpToolProvider.DisposeAsync(); + } + + 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/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/DefaultMcpToolHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultMcpToolHandler.cs new file mode 100644 index 0000000000..ba2aa9f0fd --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultMcpToolHandler.cs @@ -0,0 +1,321 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Microsoft.Extensions.AI; +using ModelContextProtocol.Authentication; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace Microsoft.Agents.AI.Workflows.Declarative; + +/// +/// Default implementation of using the MCP C# SDK. +/// +/// +/// +/// This provider supports multiple authentication strategies: +/// +/// +/// +/// +/// TokenCredential (silent auth): Uses Azure.Core to acquire tokens silently. +/// This is the preferred method for Azure Managed Identity, Service Principal, or any Azure AD-based auth. +/// +/// +/// +/// +/// AuthorizationRedirectDelegate (interactive auth): Falls back to OAuth redirect flow if silent auth fails +/// or if is not provided. +/// +/// +/// +/// +/// Pre-configured HttpClient: If the supplied has authentication already configured +/// (e.g., via a ), it will be used directly for MCP server calls. +/// +/// +/// +/// +public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable +{ + private readonly TokenCredential? _tokenCredential; + private readonly string[]? _tokenScopes; + private readonly AuthorizationRedirectDelegate? _authorizationHandler; + private readonly HttpClient? _httpClient; + private readonly bool _disposeHttpClient; + private readonly bool _httpClientHasAuth; + private readonly Dictionary _clients = []; + private readonly SemaphoreSlim _clientLock = new(1, 1); + + /// + /// Initializes a new instance of the class. + /// + /// + /// An optional for silent token acquisition (e.g., DefaultAzureCredential, ManagedIdentityCredential). + /// If provided, tokens will be acquired silently before falling back to the redirect handler. + /// + /// + /// The scopes to request when acquiring tokens. Required if is provided. + /// For example: new[] { "https://api.example.com/.default" } + /// + /// + /// An optional delegate to handle OAuth authorization redirect flows. Used as a fallback if silent auth fails. + /// Use the MCP SDK's type. + /// + /// + /// An optional HTTP client to use for connections. If not provided, a default client will be created. + /// + /// + /// Set to true if the supplied already has authentication configured + /// (e.g., via a that adds Authorization headers). When true, the provider + /// will not configure any additional authentication and will rely on the client's existing auth setup. + /// + public DefaultMcpToolHandler( + TokenCredential? tokenCredential = null, + string[]? tokenScopes = null, + AuthorizationRedirectDelegate? authorizationHandler = null, + HttpClient? httpClient = null, + bool httpClientHasAuth = false) + { + this._tokenCredential = tokenCredential; + this._tokenScopes = tokenScopes; + this._authorizationHandler = authorizationHandler; + this._httpClientHasAuth = httpClientHasAuth; + + if (httpClient is not null) + { + this._httpClient = httpClient; + this._disposeHttpClient = false; + } + else + { + this._httpClient = new HttpClient(); + this._disposeHttpClient = true; + } + } + + /// + public async Task InvokeToolAsync( + string serverUrl, + string? serverLabel, + string toolName, + IDictionary? arguments, + IDictionary? headers, + string? connectionName, + CancellationToken cancellationToken = default) + { + McpServerToolResultContent resultContent = new("McpServerToolcallId"); + McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, connectionName, 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(); + } + finally + { + this._clientLock.Release(); + } + + if (this._disposeHttpClient) + { + this._httpClient?.Dispose(); + } + + this._clientLock.Dispose(); + } + + private async Task GetOrCreateClientAsync( + string serverUrl, + string? serverLabel, + IDictionary? headers, + string? connectionName, + CancellationToken cancellationToken) + { + string cacheKey = $"{serverUrl}|{serverLabel}|{connectionName}"; + + await this._clientLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (this._clients.TryGetValue(cacheKey, out McpClient? existingClient)) + { + return existingClient; + } + + McpClient newClient = await this.CreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false); + this._clients[cacheKey] = newClient; + return newClient; + } + finally + { + this._clientLock.Release(); + } + } + + private async Task CreateClientAsync( + string serverUrl, + string? serverLabel, + IDictionary? headers, + CancellationToken cancellationToken) + { + // Merge headers with token if using TokenCredential + IDictionary? effectiveHeaders = headers; + if (this._tokenCredential is not null && this._tokenScopes is not null && !this._httpClientHasAuth) + { + effectiveHeaders = await this.AddTokenToHeadersAsync(headers, cancellationToken).ConfigureAwait(false); + } + + HttpClientTransportOptions transportOptions = new() + { + Endpoint = new Uri(serverUrl), + Name = serverLabel ?? "McpClient", + AdditionalHeaders = effectiveHeaders, + TransportMode = HttpTransportMode.AutoDetect + }; + + // Configure OAuth redirect handler if provided and not using pre-configured HttpClient auth + if (this._authorizationHandler is not null && !this._httpClientHasAuth) + { + transportOptions.OAuth = new() + { + DynamicClientRegistration = new() + { + ClientName = serverLabel ?? "WorkflowMcpClient", + }, + RedirectUri = new Uri("http://localhost:0/callback"), + AuthorizationRedirectDelegate = this._authorizationHandler, + }; + } + + HttpClientTransport transport = new(transportOptions, this._httpClient!); + + return await McpClient.CreateAsync(transport, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + private async Task?> AddTokenToHeadersAsync( + IDictionary? existingHeaders, + CancellationToken cancellationToken) + { + if (this._tokenCredential is null || this._tokenScopes is null) + { + return existingHeaders; + } + + try + { + // Acquire token silently + TokenRequestContext context = new(this._tokenScopes); + AccessToken token = await this._tokenCredential.GetTokenAsync(context, cancellationToken).ConfigureAwait(false); + + // Create or copy headers and add Authorization + Dictionary headers = existingHeaders is not null + ? new Dictionary(existingHeaders) + : []; + + headers["Authorization"] = $"Bearer {token.Token}"; + return headers; + } + catch (Exception) + { + // If silent auth fails, return original headers and let OAuth redirect handle it + return existingHeaders; + } + } + + 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/Extensions/JsonDocumentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/JsonDocumentExtensions.cs index 9d3c5e335d..0a4ce37694 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. + /// + internal 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 = @@ -116,6 +150,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, }; @@ -283,9 +318,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..58143210b6 --- /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, or if the tool produced no result. + /// + 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/Microsoft.Agents.AI.Workflows.Declarative.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj index 0f8bb826a9..e6a3e7555f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj @@ -20,12 +20,14 @@ + + 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 615d4d88ab..35b6cf69de 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs @@ -203,7 +203,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, @@ -223,40 +223,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..d9e92b9b16 --- /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 Azure; +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; + + /// + 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/InvokeFunctionToolWorkflowTest.cs deleted file mode 100644 index 289fbe2faa..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeFunctionToolWorkflowTest.cs +++ /dev/null @@ -1,155 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using Microsoft.Agents.AI.Workflows.Declarative.Events; -using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents; -using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework; -using Microsoft.Agents.AI.Workflows.Declarative.Kit; -using Microsoft.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. -/// -public sealed class InvokeFunctionToolWorkflowTest(ITestOutputHelper output) : IntegrationTest(output) -{ - [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); - - /// - /// Runs an InvokeTool workflow test with the specified configuration. - /// This method is designed to be generic and reusable for different InvokeTool types. - /// - /// 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( - string workflowFileName, - string[] expectedFunctionCalls, - string? expectedResultContains = null) - { - // Arrange - string workflowPath = GetWorkflowPath(workflowFileName); - IEnumerable functionTools = new MenuPlugin().GetTools(); - Dictionary functionMap = functionTools.ToDictionary(tool => tool.Name, tool => tool); - DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync(externalConversation: false); - Workflow workflow = DeclarativeWorkflowBuilder.Build(workflowPath, workflowOptions); - - WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(workflowPath)); - List invokedFunctions = []; - - // Act - Run workflow and handle function 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 functionResults = await this.ProcessFunctionCallsAsync( - toolRequest, - functionMap, - invokedFunctions).ConfigureAwait(false); - - ChatMessage resultMessage = new(ChatRole.Tool, functionResults); - 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) - { - // No more input events from the last resume - workflow completed - break; - } - } - - // Assert - Verify function calls were made in expected order - Assert.Equal(expectedFunctionCalls.Length, invokedFunctions.Count); - for (int i = 0; i < expectedFunctionCalls.Length; i++) - { - Assert.Equal(expectedFunctionCalls[i], invokedFunctions[i]); - } - - // Assert - Verify executor and action events - Assert.NotEmpty(workflowEvents.ExecutorInvokeEvents); - Assert.NotEmpty(workflowEvents.ExecutorCompleteEvents); - Assert.NotEmpty(workflowEvents.ActionInvokeEvents); - - // 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); - } - } - - /// - /// Processes function calls from an external input request. - /// Handles both regular function calls and approval requests. - /// - private async Task> ProcessFunctionCallsAsync( - ExternalInputRequest toolRequest, - Dictionary functionMap, - List invokedFunctions) - { - List results = []; - - foreach (ChatMessage message in toolRequest.AgentResponse.Messages) - { - // Handle approval requests if present - foreach (FunctionApprovalRequestContent approvalRequest in message.Contents.OfType()) - { - this.Output.WriteLine($"APPROVAL REQUEST: {approvalRequest.FunctionCall.Name}"); - // Auto-approve for testing - results.Add(approvalRequest.CreateResponse(approved: true)); - } - - // Handle function calls - foreach (FunctionCallContent functionCall in message.Contents.OfType()) - { - this.Output.WriteLine($"FUNCTION CALL: {functionCall.Name}"); - - if (!functionMap.TryGetValue(functionCall.Name, out AIFunction? functionTool)) - { - Assert.Fail($"Function not found: {functionCall.Name}"); - continue; - } - - invokedFunctions.Add(functionCall.Name); - - // Execute the function - AIFunctionArguments? functionArguments = functionCall.Arguments is null - ? null - : new(functionCall.Arguments.NormalizePortableValues()); - - object? result = await functionTool.InvokeAsync(functionArguments).ConfigureAwait(false); - results.Add(new FunctionResultContent(functionCall.CallId, JsonSerializer.Serialize(result))); - - this.Output.WriteLine($"FUNCTION RESULT: {JsonSerializer.Serialize(result)}"); - } - } - - return results; - } - - private static string GetWorkflowPath(string workflowFileName) => - Path.Combine(Environment.CurrentDirectory, "Workflows", workflowFileName); -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs new file mode 100644 index 0000000000..47e2f7d09a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs @@ -0,0 +1,302 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.Events; +using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents; +using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework; +using Microsoft.Agents.AI.Workflows.Declarative.Kit; +using Microsoft.Extensions.AI; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests; + +/// +/// Integration tests for InvokeFunctionTool and InvokeMcpTool actions. +/// +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.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", null, 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 InvokeFunctionTool workflow test with the specified configuration. + /// + private async Task RunInvokeFunctionToolTestAsync( + string workflowFileName, + string[] expectedFunctionCalls, + string? expectedResultContains = null) + { + // Arrange + string workflowPath = GetWorkflowPath(workflowFileName); + IEnumerable functionTools = new MenuPlugin().GetTools(); + Dictionary functionMap = functionTools.ToDictionary(tool => tool.Name, tool => tool); + DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync(externalConversation: false); + Workflow workflow = DeclarativeWorkflowBuilder.Build(workflowPath, workflowOptions); + + WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(workflowPath)); + List invokedFunctions = []; + + // Act - Run workflow and handle function 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 functionResults = await this.ProcessFunctionCallsAsync( + toolRequest, + functionMap, + invokedFunctions).ConfigureAwait(false); + + ChatMessage resultMessage = new(ChatRole.Tool, functionResults); + 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 function calls were made in expected order + Assert.Equal(expectedFunctionCalls.Length, invokedFunctions.Count); + for (int i = 0; i < expectedFunctionCalls.Length; i++) + { + Assert.Equal(expectedFunctionCalls[i], invokedFunctions[i]); + } + + // Assert - Verify executor and action events + AssertWorkflowEventsEmitted(workflowEvents); + + // Assert - Verify expected result if specified + if (expectedResultContains is not null) + { + AssertResultContains(workflowEvents, expectedResultContains); + } + } + + /// + /// Processes function calls from an external input request. + /// Handles both regular function calls and approval requests. + /// + private async Task> ProcessFunctionCallsAsync( + ExternalInputRequest toolRequest, + Dictionary functionMap, + List invokedFunctions) + { + List results = []; + + foreach (ChatMessage message in toolRequest.AgentResponse.Messages) + { + // Handle approval requests if present + foreach (FunctionApprovalRequestContent approvalRequest in message.Contents.OfType()) + { + this.Output.WriteLine($"APPROVAL REQUEST: {approvalRequest.FunctionCall.Name}"); + // Auto-approve for testing + results.Add(approvalRequest.CreateResponse(approved: true)); + } + + // Handle function calls + foreach (FunctionCallContent functionCall in message.Contents.OfType()) + { + this.Output.WriteLine($"FUNCTION CALL: {functionCall.Name}"); + + if (!functionMap.TryGetValue(functionCall.Name, out AIFunction? functionTool)) + { + Assert.Fail($"Function not found: {functionCall.Name}"); + continue; + } + + invokedFunctions.Add(functionCall.Name); + + // Execute the function + AIFunctionArguments? functionArguments = functionCall.Arguments is null + ? null + : new(functionCall.Arguments.NormalizePortableValues()); + + object? result = await functionTool.InvokeAsync(functionArguments).ConfigureAwait(false); + results.Add(new FunctionResultContent(functionCall.CallId, JsonSerializer.Serialize(result))); + + this.Output.WriteLine($"FUNCTION RESULT: {JsonSerializer.Serialize(result)}"); + } + } + + 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)); + bool toolInvoked = false; + + // 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, + ref toolInvoked); + + 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); + } + + // Assert - If approval was required and rejected, tool should not have been invoked + if (requireApproval && !approveRequest) + { + Assert.False(toolInvoked, "Tool should not have been invoked when approval was rejected"); + } + + // 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, + ref bool toolInvoked) + { + 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")}"); + } + + // Handle MCP tool result content (when tool was invoked directly without approval) + foreach (McpServerToolResultContent resultContent in message.Contents.OfType()) + { + toolInvoked = true; + this.Output.WriteLine($"MCP TOOL RESULT: {resultContent.CallId}"); + if (resultContent.Output is not null) + { + foreach (AIContent content in resultContent.Output) + { + if (content is TextContent textContent) + { + this.Output.WriteLine($" Content: {textContent.Text?.Substring(0, Math.Min(textContent.Text.Length, 200))}..."); + } + } + } + } + } + + 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/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.UnitTests/Extensions/JsonDocumentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/JsonDocumentExtensionsTests.cs index bd86aa1958..e94194673f 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 @@ -384,4 +384,208 @@ public void ParseList_Array_MixedTypes_Throws() // Act / Assert Assert.Throws(() => document.ParseList(typeof(int[]))); } + + [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 +} From c00a2dc17b1a0234eed50ce7a029f560d737438b Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Mon, 23 Feb 2026 18:08:44 -0800 Subject: [PATCH 2/8] Cleaned up sample implementation --- dotnet/Directory.Packages.props | 7 +- dotnet/nuget.config | 6 - .../InvokeMcpTool/InvokeMcpTool.yaml | 26 ++- .../Declarative/InvokeMcpTool/Program.cs | 67 +++++-- .../DefaultMcpToolHandler.cs | 167 ++++-------------- ...oft.Agents.AI.Workflows.Declarative.csproj | 1 - .../ObjectModel/InvokeMcpToolExecutor.cs | 1 - 7 files changed, 113 insertions(+), 162 deletions(-) diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index e2ae6a98cf..396a316575 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -23,7 +23,6 @@ - @@ -112,9 +111,9 @@ - - - + + + diff --git a/dotnet/nuget.config b/dotnet/nuget.config index dfd4634b12..76d943ce16 100644 --- a/dotnet/nuget.config +++ b/dotnet/nuget.config @@ -3,16 +3,10 @@ - - - - - - \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.yaml b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.yaml index 5b2dd14139..7b942cb2bd 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.yaml +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.yaml @@ -5,13 +5,8 @@ # The workflow: # 1. Accepts a model search term as input # 2. Invokes the Foundry MCP tool -# 3. Sends the search results as an agent message using the McpSearchAgent -# -# MCP Tool Configuration: -# - serverUrl: The MCP server endpoint (https://mcp.ai.azure.com) -# - serverLabel: A label for the server (azure_mcp_server) #optional -# - toolName: The name of the tool to invoke (model_details_get) -# - arguments: Tool arguments (model to search) +# 3. Invokes the Microsoft Learn MCP tool +# 4. Uses an agent to summarize the results # # Example input: # gpt-4.1 @@ -31,7 +26,7 @@ trigger: # Invoke MCP search tool on Foundry MCP server - kind: InvokeMcpTool - id: invoke_mcp_search + id: invoke_foundry_search serverUrl: https://mcp.ai.azure.com serverLabel: azure_mcp_server toolName: model_details_get @@ -40,7 +35,20 @@ trigger: modelName: =Local.SearchQuery output: autoSend: true - result: Local.SearchResult + 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 diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/Program.cs index b2f7faa2c0..21012e9ec4 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/Program.cs @@ -6,6 +6,7 @@ using Azure.AI.Projects; using Azure.AI.Projects.OpenAI; +using Azure.Core; using Azure.Identity; using Microsoft.Agents.AI.Workflows.Declarative; using Microsoft.Extensions.Configuration; @@ -50,21 +51,65 @@ public static async Task Main(string[] args) // Get input from command line or console string workflowInput = Application.GetInput(args); - // Create the MCP tool provider for invoking MCP server tools - DefaultMcpToolHandler mcpToolProvider = new(tokenCredential: new DefaultAzureCredential(), tokenScopes: ["https://mcp.ai.azure.com"]); + // Create the MCP tool provider for invoking MCP server tools. + // The httpClientProvider delegate 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 mcpToolProvider = 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; + } - // Create the workflow factory with MCP tool provider - WorkflowFactory workflowFactory = new("InvokeMcpTool.yaml", foundryEndpoint) + // Return null for unknown servers to use the default HttpClient + return null; + }); + + try { - McpToolHandler = mcpToolProvider - }; + // Create the workflow factory with MCP tool provider + WorkflowFactory workflowFactory = new("InvokeMcpTool.yaml", foundryEndpoint) + { + McpToolHandler = mcpToolProvider + }; - // Execute the workflow - WorkflowRunner runner = new() { UseJsonCheckpoints = true }; - await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput); + // Execute the workflow + WorkflowRunner runner = new() { UseJsonCheckpoints = true }; + await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput); + } + finally + { + // Clean up connections and dispose created HttpClients + await mcpToolProvider.DisposeAsync(); - // Clean up MCP connections - await mcpToolProvider.DisposeAsync(); + foreach (HttpClient httpClient in createdHttpClients) + { + httpClient.Dispose(); + } + } } private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultMcpToolHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultMcpToolHandler.cs index ba2aa9f0fd..d8201eaffb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultMcpToolHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultMcpToolHandler.cs @@ -5,9 +5,7 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using Azure.Core; using Microsoft.Extensions.AI; -using ModelContextProtocol.Authentication; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -17,86 +15,28 @@ namespace Microsoft.Agents.AI.Workflows.Declarative; /// Default implementation of using the MCP C# SDK. /// /// -/// -/// This provider supports multiple authentication strategies: -/// -/// -/// -/// -/// TokenCredential (silent auth): Uses Azure.Core to acquire tokens silently. -/// This is the preferred method for Azure Managed Identity, Service Principal, or any Azure AD-based auth. -/// -/// -/// -/// -/// AuthorizationRedirectDelegate (interactive auth): Falls back to OAuth redirect flow if silent auth fails -/// or if is not provided. -/// -/// -/// -/// -/// Pre-configured HttpClient: If the supplied has authentication already configured -/// (e.g., via a ), it will be used directly for MCP server calls. -/// -/// -/// +/// 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 TokenCredential? _tokenCredential; - private readonly string[]? _tokenScopes; - private readonly AuthorizationRedirectDelegate? _authorizationHandler; - private readonly HttpClient? _httpClient; - private readonly bool _disposeHttpClient; - private readonly bool _httpClientHasAuth; + 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 for silent token acquisition (e.g., DefaultAzureCredential, ManagedIdentityCredential). - /// If provided, tokens will be acquired silently before falling back to the redirect handler. + /// + /// 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. /// - /// - /// The scopes to request when acquiring tokens. Required if is provided. - /// For example: new[] { "https://api.example.com/.default" } - /// - /// - /// An optional delegate to handle OAuth authorization redirect flows. Used as a fallback if silent auth fails. - /// Use the MCP SDK's type. - /// - /// - /// An optional HTTP client to use for connections. If not provided, a default client will be created. - /// - /// - /// Set to true if the supplied already has authentication configured - /// (e.g., via a that adds Authorization headers). When true, the provider - /// will not configure any additional authentication and will rely on the client's existing auth setup. - /// - public DefaultMcpToolHandler( - TokenCredential? tokenCredential = null, - string[]? tokenScopes = null, - AuthorizationRedirectDelegate? authorizationHandler = null, - HttpClient? httpClient = null, - bool httpClientHasAuth = false) + public DefaultMcpToolHandler(Func>? httpClientProvider = null) { - this._tokenCredential = tokenCredential; - this._tokenScopes = tokenScopes; - this._authorizationHandler = authorizationHandler; - this._httpClientHasAuth = httpClientHasAuth; - - if (httpClient is not null) - { - this._httpClient = httpClient; - this._disposeHttpClient = false; - } - else - { - this._httpClient = new HttpClient(); - this._disposeHttpClient = true; - } + this._httpClientProvider = httpClientProvider; } /// @@ -109,8 +49,9 @@ public async Task InvokeToolAsync( string? connectionName, CancellationToken cancellationToken = default) { + //TODO: Handle connectionName and server label appropriately when Hosted scenario supports them. For now, ignore McpServerToolResultContent resultContent = new("McpServerToolcallId"); - McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, connectionName, cancellationToken).ConfigureAwait(false); + McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false); // Convert IDictionary to IReadOnlyDictionary for CallToolAsync IReadOnlyDictionary? readOnlyArguments = arguments is null @@ -140,17 +81,20 @@ public async ValueTask DisposeAsync() } 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(); } - if (this._disposeHttpClient) - { - this._httpClient?.Dispose(); - } - this._clientLock.Dispose(); } @@ -158,10 +102,9 @@ private async Task GetOrCreateClientAsync( string serverUrl, string? serverLabel, IDictionary? headers, - string? connectionName, CancellationToken cancellationToken) { - string cacheKey = $"{serverUrl}|{serverLabel}|{connectionName}"; + string cacheKey = $"{serverUrl.Trim().ToUpperInvariant()}"; await this._clientLock.WaitAsync(cancellationToken).ConfigureAwait(false); try @@ -171,7 +114,7 @@ private async Task GetOrCreateClientAsync( return existingClient; } - McpClient newClient = await this.CreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false); + McpClient newClient = await this.CreateClientAsync(serverUrl, serverLabel, headers, cacheKey, cancellationToken).ConfigureAwait(false); this._clients[cacheKey] = newClient; return newClient; } @@ -185,72 +128,36 @@ private async Task CreateClientAsync( string serverUrl, string? serverLabel, IDictionary? headers, + string cacheKey, CancellationToken cancellationToken) { - // Merge headers with token if using TokenCredential - IDictionary? effectiveHeaders = headers; - if (this._tokenCredential is not null && this._tokenScopes is not null && !this._httpClientHasAuth) + // Get HttpClient from provider or create a default one + HttpClient? httpClient = null; + + if (this._httpClientProvider is not null) + { + httpClient = await this._httpClientProvider(serverUrl, cancellationToken).ConfigureAwait(false); + } + + if (httpClient is null) { - effectiveHeaders = await this.AddTokenToHeadersAsync(headers, cancellationToken).ConfigureAwait(false); + httpClient = new HttpClient(); + this._ownedHttpClients[cacheKey] = httpClient; } HttpClientTransportOptions transportOptions = new() { Endpoint = new Uri(serverUrl), Name = serverLabel ?? "McpClient", - AdditionalHeaders = effectiveHeaders, + AdditionalHeaders = headers, TransportMode = HttpTransportMode.AutoDetect }; - // Configure OAuth redirect handler if provided and not using pre-configured HttpClient auth - if (this._authorizationHandler is not null && !this._httpClientHasAuth) - { - transportOptions.OAuth = new() - { - DynamicClientRegistration = new() - { - ClientName = serverLabel ?? "WorkflowMcpClient", - }, - RedirectUri = new Uri("http://localhost:0/callback"), - AuthorizationRedirectDelegate = this._authorizationHandler, - }; - } - - HttpClientTransport transport = new(transportOptions, this._httpClient!); + HttpClientTransport transport = new(transportOptions, httpClient); return await McpClient.CreateAsync(transport, cancellationToken: cancellationToken).ConfigureAwait(false); } - private async Task?> AddTokenToHeadersAsync( - IDictionary? existingHeaders, - CancellationToken cancellationToken) - { - if (this._tokenCredential is null || this._tokenScopes is null) - { - return existingHeaders; - } - - try - { - // Acquire token silently - TokenRequestContext context = new(this._tokenScopes); - AccessToken token = await this._tokenCredential.GetTokenAsync(context, cancellationToken).ConfigureAwait(false); - - // Create or copy headers and add Authorization - Dictionary headers = existingHeaders is not null - ? new Dictionary(existingHeaders) - : []; - - headers["Authorization"] = $"Bearer {token.Token}"; - return headers; - } - catch (Exception) - { - // If silent auth fails, return original headers and let OAuth redirect handle it - return existingHeaders; - } - } - private static void PopulateResultContent(McpServerToolResultContent resultContent, CallToolResult result) { // Ensure Output list is initialized diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj index e6a3e7555f..c615e57d49 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj @@ -20,7 +20,6 @@ - diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs index d9e92b9b16..4d97cb42bb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs @@ -5,7 +5,6 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Azure; using Microsoft.Agents.AI.Workflows.Declarative.Events; using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; From e31afb241c5b4d8884e030204480d6a7aef82b0d Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Mon, 23 Feb 2026 18:32:31 -0800 Subject: [PATCH 3/8] Updated sample comments. --- .../Declarative/InvokeMcpTool/Program.cs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/Program.cs index 21012e9ec4..3c9c1fce73 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/Program.cs @@ -30,7 +30,9 @@ namespace Demo.Workflows.Declarative.InvokeMcpTool; /// Integrating with MCP-compatible services /// /// -/// This sample uses the Microsoft Learn MCP server to search Azure documentation. +/// 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 @@ -51,14 +53,14 @@ public static async Task Main(string[] args) // Get input from command line or console string workflowInput = Application.GetInput(args); - // Create the MCP tool provider for invoking MCP server tools. - // The httpClientProvider delegate allows configuring authentication per MCP server. + // 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 mcpToolProvider = new( + DefaultMcpToolHandler mcpToolHandler = new( httpClientProvider: async (serverUrl, cancellationToken) => { if (serverUrl.StartsWith("https://mcp.ai.azure.com", StringComparison.OrdinalIgnoreCase)) @@ -84,7 +86,7 @@ public static async Task Main(string[] args) return httpClient; } - // Return null for unknown servers to use the default HttpClient + // Return null for unknown servers to use the default HttpClient without auth. return null; }); @@ -93,7 +95,7 @@ public static async Task Main(string[] args) // Create the workflow factory with MCP tool provider WorkflowFactory workflowFactory = new("InvokeMcpTool.yaml", foundryEndpoint) { - McpToolHandler = mcpToolProvider + McpToolHandler = mcpToolHandler }; // Execute the workflow @@ -103,7 +105,7 @@ public static async Task Main(string[] args) finally { // Clean up connections and dispose created HttpClients - await mcpToolProvider.DisposeAsync(); + await mcpToolHandler.DisposeAsync(); foreach (HttpClient httpClient in createdHttpClients) { From 9322fa7763d7b7237bd032163f83440b49e8287d Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Mon, 23 Feb 2026 20:23:24 -0800 Subject: [PATCH 4/8] Added missing executor routing attribute --- .../ObjectModel/InvokeMcpToolExecutor.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs index 4d97cb42bb..45929f20f7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs @@ -60,6 +60,7 @@ public static class Steps protected override bool IsDiscreteAction => false; /// + [SendsMessage(typeof(ExternalInputRequest))] protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default) { string serverUrl = this.GetServerUrl(); From d6e1f6f76ad88344dd76f48d503f7b778e2d94b7 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Mon, 23 Feb 2026 21:41:44 -0800 Subject: [PATCH 5/8] Fix PR comments. --- .../IMcpToolHandler.cs | 2 +- .../InvokeToolWorkflowTest.cs | 32 ++----------------- 2 files changed, 4 insertions(+), 30 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IMcpToolHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IMcpToolHandler.cs index 58143210b6..56b1c3deb4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IMcpToolHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IMcpToolHandler.cs @@ -28,7 +28,7 @@ public interface IMcpToolHandler /// A token to observe cancellation. /// /// A task representing the asynchronous operation. The result contains a - /// with the tool invocation output, or if the tool produced no result. + /// with the tool invocation output. /// Task InvokeToolAsync( string serverUrl, diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs index 47e2f7d09a..e0b309c504 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs @@ -39,7 +39,7 @@ public Task ValidateInvokeMcpToolAsync(string workflowFileName, string? expected [Theory] [InlineData("InvokeMcpToolWithApproval.yaml", "Azure OpenAI", true)] - [InlineData("InvokeMcpToolWithApproval.yaml", null, false)] + [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); @@ -180,7 +180,6 @@ private async Task RunInvokeMcpToolTestAsync( Workflow workflow = DeclarativeWorkflowBuilder.Build(workflowPath, workflowOptions); WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(workflowPath)); - bool toolInvoked = false; // Act - Run workflow and handle MCP tool invocations WorkflowEvents workflowEvents = await harness.RunWorkflowAsync("start").ConfigureAwait(false); @@ -193,8 +192,7 @@ private async Task RunInvokeMcpToolTestAsync( IList mcpResults = this.ProcessMcpToolRequests( toolRequest, - approveRequest, - ref toolInvoked); + approveRequest); ChatMessage resultMessage = new(ChatRole.Tool, mcpResults); WorkflowEvents resumeEvents = await harness.ResumeAsync( @@ -218,12 +216,6 @@ private async Task RunInvokeMcpToolTestAsync( AssertResultContains(workflowEvents, expectedResultContains); } - // Assert - If approval was required and rejected, tool should not have been invoked - if (requireApproval && !approveRequest) - { - Assert.False(toolInvoked, "Tool should not have been invoked when approval was rejected"); - } - // Cleanup await mcpToolProvider.DisposeAsync().ConfigureAwait(false); } @@ -234,8 +226,7 @@ private async Task RunInvokeMcpToolTestAsync( /// private List ProcessMcpToolRequests( ExternalInputRequest toolRequest, - bool approveRequest, - ref bool toolInvoked) + bool approveRequest) { List results = []; @@ -252,23 +243,6 @@ private List ProcessMcpToolRequests( this.Output.WriteLine($"MCP APPROVAL RESPONSE: {(approveRequest ? "Approved" : "Rejected")}"); } - - // Handle MCP tool result content (when tool was invoked directly without approval) - foreach (McpServerToolResultContent resultContent in message.Contents.OfType()) - { - toolInvoked = true; - this.Output.WriteLine($"MCP TOOL RESULT: {resultContent.CallId}"); - if (resultContent.Output is not null) - { - foreach (AIContent content in resultContent.Output) - { - if (content is TextContent textContent) - { - this.Output.WriteLine($" Content: {textContent.Text?.Substring(0, Math.Min(textContent.Text.Length, 200))}..."); - } - } - } - } } return results; From 9fd5fc78486304bc18dcce9d4666f05cee6717ac Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Tue, 24 Feb 2026 22:44:49 -0800 Subject: [PATCH 6/8] Updated based on PR comments. --- .../DefaultMcpToolHandler.cs | 40 +++++++++++++++---- .../Extensions/JsonDocumentExtensions.cs | 2 +- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultMcpToolHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultMcpToolHandler.cs index d8201eaffb..74a163b288 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultMcpToolHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultMcpToolHandler.cs @@ -2,6 +2,8 @@ using System; using System.Collections.Generic; +using System.Globalization; +using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -104,18 +106,19 @@ private async Task GetOrCreateClientAsync( IDictionary? headers, CancellationToken cancellationToken) { - string cacheKey = $"{serverUrl.Trim().ToUpperInvariant()}"; + string normalizedUrl = serverUrl.Trim().ToUpperInvariant(); + string clientCacheKey = $"{normalizedUrl}|{ComputeHeadersHash(headers)}"; await this._clientLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { - if (this._clients.TryGetValue(cacheKey, out McpClient? existingClient)) + if (this._clients.TryGetValue(clientCacheKey, out McpClient? existingClient)) { return existingClient; } - McpClient newClient = await this.CreateClientAsync(serverUrl, serverLabel, headers, cacheKey, cancellationToken).ConfigureAwait(false); - this._clients[cacheKey] = newClient; + McpClient newClient = await this.CreateClientAsync(serverUrl, serverLabel, headers, normalizedUrl, cancellationToken).ConfigureAwait(false); + this._clients[clientCacheKey] = newClient; return newClient; } finally @@ -128,10 +131,10 @@ private async Task CreateClientAsync( string serverUrl, string? serverLabel, IDictionary? headers, - string cacheKey, + string httpClientCacheKey, CancellationToken cancellationToken) { - // Get HttpClient from provider or create a default one + // Get or create HttpClient (Can be shared across McpClients for the same server) HttpClient? httpClient = null; if (this._httpClientProvider is not null) @@ -139,10 +142,10 @@ private async Task CreateClientAsync( httpClient = await this._httpClientProvider(serverUrl, cancellationToken).ConfigureAwait(false); } - if (httpClient is null) + if (httpClient is null && !this._ownedHttpClients.TryGetValue(httpClientCacheKey, out httpClient)) { httpClient = new HttpClient(); - this._ownedHttpClients[cacheKey] = httpClient; + this._ownedHttpClients[httpClientCacheKey] = httpClient; } HttpClientTransportOptions transportOptions = new() @@ -158,6 +161,27 @@ private async Task CreateClientAsync( 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 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 0a4ce37694..2eb699a97a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/JsonDocumentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/JsonDocumentExtensions.cs @@ -45,7 +45,7 @@ internal static class JsonDocumentExtensions /// /// Creates a VariableType.List with schema inferred from the first object element in the array. /// - internal static VariableType GetListTypeFromJson(this JsonElement arrayElement) + public static VariableType GetListTypeFromJson(this JsonElement arrayElement) { // Find the first object element to infer schema foreach (JsonElement element in arrayElement.EnumerateArray()) From c2bda80a0b6bfb6b0344fafe3ad73434a88839c0 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Wed, 25 Feb 2026 09:45:39 -0800 Subject: [PATCH 7/8] Updated based on PR comments. --- dotnet/agent-framework-dotnet.slnx | 2 + .../InvokeMcpTool/InvokeMcpTool.csproj | 1 + .../Declarative/InvokeMcpTool/Program.cs | 1 + .../DefaultMcpToolHandler.cs | 6 +- ...Agents.AI.Workflows.Declarative.Mcp.csproj | 33 ++ ...oft.Agents.AI.Workflows.Declarative.csproj | 1 - .../InvokeToolWorkflowTest.cs | 1 + ...kflows.Declarative.IntegrationTests.csproj | 1 + .../DefaultMcpToolHandlerTests.cs | 345 ++++++++++++++++++ ...Workflows.Declarative.Mcp.UnitTests.csproj | 15 + 10 files changed, 402 insertions(+), 4 deletions(-) rename dotnet/src/{Microsoft.Agents.AI.Workflows.Declarative => Microsoft.Agents.AI.Workflows.Declarative.Mcp}/DefaultMcpToolHandler.cs (97%) create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests.csproj diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index c0583764c7..68cf23ed04 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -437,6 +437,7 @@ + @@ -482,6 +483,7 @@ + diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj index cda2ae1a26..9c905deeb7 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj @@ -27,6 +27,7 @@ + diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/Program.cs index 3c9c1fce73..0007c73ffe 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/Program.cs @@ -9,6 +9,7 @@ using Azure.Core; using Azure.Identity; using Microsoft.Agents.AI.Workflows.Declarative; +using Microsoft.Agents.AI.Workflows.Declarative.Mcp; using Microsoft.Extensions.Configuration; using Shared.Foundry; using Shared.Workflows; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultMcpToolHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs similarity index 97% rename from dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultMcpToolHandler.cs rename to dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs index 74a163b288..751f518277 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultMcpToolHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs @@ -11,7 +11,7 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; -namespace Microsoft.Agents.AI.Workflows.Declarative; +namespace Microsoft.Agents.AI.Workflows.Declarative.Mcp; /// /// Default implementation of using the MCP C# SDK. @@ -51,8 +51,8 @@ public async Task InvokeToolAsync( string? connectionName, CancellationToken cancellationToken = default) { - //TODO: Handle connectionName and server label appropriately when Hosted scenario supports them. For now, ignore - McpServerToolResultContent resultContent = new("McpServerToolcallId"); + // 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 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/Microsoft.Agents.AI.Workflows.Declarative.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj index 092a55b971..b8b32f3b06 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj @@ -26,7 +26,6 @@ - diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs index e0b309c504..359d9389a6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs @@ -10,6 +10,7 @@ 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; 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.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 + + + + + + + + + + + From 3867d37c9121c7705a28353c65edc2fad19f99e8 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Wed, 25 Feb 2026 10:28:59 -0800 Subject: [PATCH 8/8] Removed unnecessary using statement. --- .../Workflows/Declarative/InvokeMcpTool/Program.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/Program.cs index 0007c73ffe..3afebc560b 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/Program.cs @@ -8,7 +8,6 @@ using Azure.AI.Projects.OpenAI; using Azure.Core; using Azure.Identity; -using Microsoft.Agents.AI.Workflows.Declarative; using Microsoft.Agents.AI.Workflows.Declarative.Mcp; using Microsoft.Extensions.Configuration; using Shared.Foundry;