diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index 0de4409d53..396a316575 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -111,9 +111,9 @@
-
-
-
+
+
+
diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index 041da4a6f6..10e2ae32bf 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -227,6 +227,7 @@
+
@@ -438,6 +439,7 @@
+
@@ -483,6 +485,7 @@
+
diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj
new file mode 100644
index 0000000000..9c905deeb7
--- /dev/null
+++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj
@@ -0,0 +1,39 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+ true
+ true
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
+
+
diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.yaml b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.yaml
new file mode 100644
index 0000000000..7b942cb2bd
--- /dev/null
+++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.yaml
@@ -0,0 +1,63 @@
+#
+# This workflow demonstrates invoking MCP tools directly from a declarative workflow.
+# Uses the Foundry MCP server to search AI model details.
+#
+# The workflow:
+# 1. Accepts a model search term as input
+# 2. Invokes the Foundry MCP tool
+# 3. Invokes the Microsoft Learn MCP tool
+# 4. Uses an agent to summarize the results
+#
+# Example input:
+# gpt-4.1
+#
+kind: Workflow
+trigger:
+
+ kind: OnConversationStart
+ id: workflow_invoke_mcp_tool
+ actions:
+
+ # Set the search query from user input or use default
+ - kind: SetVariable
+ id: set_search_query
+ variable: Local.SearchQuery
+ value: =System.LastMessage.Text
+
+ # Invoke MCP search tool on Foundry MCP server
+ - kind: InvokeMcpTool
+ id: invoke_foundry_search
+ serverUrl: https://mcp.ai.azure.com
+ serverLabel: azure_mcp_server
+ toolName: model_details_get
+ conversationId: =System.ConversationId
+ arguments:
+ modelName: =Local.SearchQuery
+ output:
+ autoSend: true
+ result: Local.FoundrySearchResult
+
+ # Invoke MCP search tool on Microsoft Learn server
+ - kind: InvokeMcpTool
+ id: invoke_docs_search
+ serverUrl: https://learn.microsoft.com/api/mcp
+ serverLabel: microsoft_docs
+ toolName: microsoft_docs_search
+ conversationId: =System.ConversationId
+ arguments:
+ query: =Local.SearchQuery
+ output:
+ autoSend: true
+ result: Local.DocsSearchResult
+
+ # Use the search agent to provide a helpful response based on results
+ - kind: InvokeAzureAgent
+ id: summarize_results
+ agent:
+ name: McpSearchAgent
+ conversationId: =System.ConversationId
+ input:
+ messages: =UserMessage("Based on the search results for '" & Local.SearchQuery & "', please provide a helpful summary.")
+ output:
+ autoSend: true
+ result: Local.Summary
diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/Program.cs
new file mode 100644
index 0000000000..3afebc560b
--- /dev/null
+++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/Program.cs
@@ -0,0 +1,141 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// This sample demonstrates using the InvokeMcpTool action to call MCP (Model Context Protocol)
+// server tools directly from a declarative workflow. MCP servers expose tools that can be
+// invoked to perform specific tasks, like searching documentation or executing operations.
+
+using Azure.AI.Projects;
+using Azure.AI.Projects.OpenAI;
+using Azure.Core;
+using Azure.Identity;
+using Microsoft.Agents.AI.Workflows.Declarative.Mcp;
+using Microsoft.Extensions.Configuration;
+using Shared.Foundry;
+using Shared.Workflows;
+
+namespace Demo.Workflows.Declarative.InvokeMcpTool;
+
+///
+/// Demonstrates a workflow that uses InvokeMcpTool to call MCP server tools
+/// directly from the workflow.
+///
+///
+///
+/// The InvokeMcpTool action allows workflows to invoke tools on MCP (Model Context Protocol)
+/// servers. This enables:
+///
+///
+/// - Searching external data sources like documentation
+/// - Executing operations on remote servers
+/// - Integrating with MCP-compatible services
+///
+///
+/// This sample uses the Microsoft Learn MCP server to search Azure documentation and the Azure foundry MCP server to get AI model details.
+/// When you run the sample, provide an AI model (e.g. gpt-4.1-mini) as input,
+/// The workflow will use the MCP tools to find relevant information about the model from Microsoft Learn and foundry, then an agent will summarize the results.
+///
+///
+/// See the README.md file in the parent folder (../README.md) for detailed
+/// information about the configuration required to run this sample.
+///
+///
+internal sealed class Program
+{
+ public static async Task Main(string[] args)
+ {
+ // Initialize configuration
+ IConfiguration configuration = Application.InitializeConfig();
+ Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint));
+
+ // Ensure sample agent exists in Foundry
+ await CreateAgentAsync(foundryEndpoint, configuration);
+
+ // Get input from command line or console
+ string workflowInput = Application.GetInput(args);
+
+ // Create the MCP tool handler for invoking MCP server tools.
+ // The HttpClient callback allows configuring authentication per MCP server.
+ // Different MCP servers may require different authentication configurations.
+ // For Production scenarios, consider implementing a more robust HttpClient management strategy to reuse HttpClient instances and manage their lifetimes appropriately.
+ List createdHttpClients = [];
+ // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
+ DefaultAzureCredential credential = new();
+ DefaultMcpToolHandler mcpToolHandler = new(
+ httpClientProvider: async (serverUrl, cancellationToken) =>
+ {
+ if (serverUrl.StartsWith("https://mcp.ai.azure.com", StringComparison.OrdinalIgnoreCase))
+ {
+ // Acquire token for the Azure MCP server
+ AccessToken token = await credential.GetTokenAsync(
+ new TokenRequestContext(["https://mcp.ai.azure.com/.default"]),
+ cancellationToken);
+
+ // Create HttpClient with Authorization header
+ HttpClient httpClient = new();
+ httpClient.DefaultRequestHeaders.Authorization =
+ new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token.Token);
+ createdHttpClients.Add(httpClient);
+ return httpClient;
+ }
+
+ if (serverUrl.StartsWith("https://learn.microsoft.com", StringComparison.OrdinalIgnoreCase))
+ {
+ // Microsoft Learn MCP server does not require authentication
+ HttpClient httpClient = new();
+ createdHttpClients.Add(httpClient);
+ return httpClient;
+ }
+
+ // Return null for unknown servers to use the default HttpClient without auth.
+ return null;
+ });
+
+ try
+ {
+ // Create the workflow factory with MCP tool provider
+ WorkflowFactory workflowFactory = new("InvokeMcpTool.yaml", foundryEndpoint)
+ {
+ McpToolHandler = mcpToolHandler
+ };
+
+ // Execute the workflow
+ WorkflowRunner runner = new() { UseJsonCheckpoints = true };
+ await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput);
+ }
+ finally
+ {
+ // Clean up connections and dispose created HttpClients
+ await mcpToolHandler.DisposeAsync();
+
+ foreach (HttpClient httpClient in createdHttpClients)
+ {
+ httpClient.Dispose();
+ }
+ }
+ }
+
+ private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration)
+ {
+ // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
+ AIProjectClient aiProjectClient = new(foundryEndpoint, new DefaultAzureCredential());
+
+ await aiProjectClient.CreateAgentAsync(
+ agentName: "McpSearchAgent",
+ agentDefinition: DefineSearchAgent(configuration),
+ agentDescription: "Provides information based on search results");
+ }
+
+ private static PromptAgentDefinition DefineSearchAgent(IConfiguration configuration)
+ {
+ return new PromptAgentDefinition(configuration.GetValue(Application.Settings.FoundryModelMini))
+ {
+ Instructions =
+ """
+ You are a helpful assistant that answers questions based on search results.
+ Use the information provided in the conversation history to answer questions.
+ If the information is already available in the conversation, use it directly.
+ Be concise and helpful in your responses.
+ """
+ };
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs
new file mode 100644
index 0000000000..751f518277
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs
@@ -0,0 +1,252 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+
+namespace Microsoft.Agents.AI.Workflows.Declarative.Mcp;
+
+///
+/// Default implementation of using the MCP C# SDK.
+///
+///
+/// This provider supports per-server authentication via the httpClientProvider callback.
+/// The callback allows different MCP servers to use different authentication configurations by returning
+/// a pre-configured for each server.
+///
+public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
+{
+ private readonly Func>? _httpClientProvider;
+ private readonly Dictionary _clients = [];
+ private readonly Dictionary _ownedHttpClients = [];
+ private readonly SemaphoreSlim _clientLock = new(1, 1);
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ /// An optional callback that provides an for each MCP server.
+ /// The callback receives (serverUrl, cancellationToken) and should return an HttpClient
+ /// configured with any required authentication. Return to use a default HttpClient with no auth.
+ ///
+ public DefaultMcpToolHandler(Func>? httpClientProvider = null)
+ {
+ this._httpClientProvider = httpClientProvider;
+ }
+
+ ///
+ public async Task InvokeToolAsync(
+ string serverUrl,
+ string? serverLabel,
+ string toolName,
+ IDictionary? arguments,
+ IDictionary? headers,
+ string? connectionName,
+ CancellationToken cancellationToken = default)
+ {
+ // TODO: Handle connectionName and server label appropriately when Hosted scenario supports them. For now, ignore
+ McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString());
+ McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false);
+
+ // Convert IDictionary to IReadOnlyDictionary for CallToolAsync
+ IReadOnlyDictionary? readOnlyArguments = arguments is null
+ ? null
+ : arguments as IReadOnlyDictionary ?? new Dictionary(arguments);
+
+ CallToolResult result = await client.CallToolAsync(
+ toolName,
+ readOnlyArguments,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ // Map MCP content blocks to MEAI AIContent types
+ PopulateResultContent(resultContent, result);
+
+ return resultContent;
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ await this._clientLock.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ foreach (McpClient client in this._clients.Values)
+ {
+ await client.DisposeAsync().ConfigureAwait(false);
+ }
+
+ this._clients.Clear();
+
+ // Dispose only HttpClients that the handler created (not user-provided ones)
+ foreach (HttpClient httpClient in this._ownedHttpClients.Values)
+ {
+ httpClient.Dispose();
+ }
+
+ this._ownedHttpClients.Clear();
+ }
+ finally
+ {
+ this._clientLock.Release();
+ }
+
+ this._clientLock.Dispose();
+ }
+
+ private async Task GetOrCreateClientAsync(
+ string serverUrl,
+ string? serverLabel,
+ IDictionary? headers,
+ CancellationToken cancellationToken)
+ {
+ string normalizedUrl = serverUrl.Trim().ToUpperInvariant();
+ string clientCacheKey = $"{normalizedUrl}|{ComputeHeadersHash(headers)}";
+
+ await this._clientLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ if (this._clients.TryGetValue(clientCacheKey, out McpClient? existingClient))
+ {
+ return existingClient;
+ }
+
+ McpClient newClient = await this.CreateClientAsync(serverUrl, serverLabel, headers, normalizedUrl, cancellationToken).ConfigureAwait(false);
+ this._clients[clientCacheKey] = newClient;
+ return newClient;
+ }
+ finally
+ {
+ this._clientLock.Release();
+ }
+ }
+
+ private async Task CreateClientAsync(
+ string serverUrl,
+ string? serverLabel,
+ IDictionary? headers,
+ string httpClientCacheKey,
+ CancellationToken cancellationToken)
+ {
+ // Get or create HttpClient (Can be shared across McpClients for the same server)
+ HttpClient? httpClient = null;
+
+ if (this._httpClientProvider is not null)
+ {
+ httpClient = await this._httpClientProvider(serverUrl, cancellationToken).ConfigureAwait(false);
+ }
+
+ if (httpClient is null && !this._ownedHttpClients.TryGetValue(httpClientCacheKey, out httpClient))
+ {
+ httpClient = new HttpClient();
+ this._ownedHttpClients[httpClientCacheKey] = httpClient;
+ }
+
+ HttpClientTransportOptions transportOptions = new()
+ {
+ Endpoint = new Uri(serverUrl),
+ Name = serverLabel ?? "McpClient",
+ AdditionalHeaders = headers,
+ TransportMode = HttpTransportMode.AutoDetect
+ };
+
+ HttpClientTransport transport = new(transportOptions, httpClient);
+
+ return await McpClient.CreateAsync(transport, cancellationToken: cancellationToken).ConfigureAwait(false);
+ }
+
+ private static string ComputeHeadersHash(IDictionary? headers)
+ {
+ if (headers is null || headers.Count == 0)
+ {
+ return string.Empty;
+ }
+
+ // Build a deterministic, sorted representation of the headers
+ // Within a single process lifetime, the hashcodes are consistent.
+ // This will ensure that the same set of headers always produces the same hash, regardless of order.
+ SortedDictionary sorted = new(headers.ToDictionary(h => h.Key.ToUpperInvariant(), h => h.Value.ToUpperInvariant()));
+ int hashCode = 17;
+ foreach (KeyValuePair kvp in sorted)
+ {
+ hashCode = (hashCode * 31) + StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Key);
+ hashCode = (hashCode * 31) + StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Value);
+ }
+
+ return hashCode.ToString(CultureInfo.InvariantCulture);
+ }
+
+ private static void PopulateResultContent(McpServerToolResultContent resultContent, CallToolResult result)
+ {
+ // Ensure Output list is initialized
+ resultContent.Output ??= [];
+
+ if (result.IsError == true)
+ {
+ // Collect error text from content blocks
+ string? errorText = null;
+ if (result.Content is not null)
+ {
+ foreach (ContentBlock block in result.Content)
+ {
+ if (block is TextContentBlock textBlock)
+ {
+ errorText = errorText is null ? textBlock.Text : $"{errorText}\n{textBlock.Text}";
+ }
+ }
+ }
+
+ resultContent.Output.Add(new TextContent($"Error: {errorText ?? "Unknown error from MCP Server call"}"));
+ return;
+ }
+
+ if (result.Content is null || result.Content.Count == 0)
+ {
+ return;
+ }
+
+ // Map each MCP content block to an MEAI AIContent type
+ foreach (ContentBlock block in result.Content)
+ {
+ AIContent content = ConvertContentBlock(block);
+ if (content is not null)
+ {
+ resultContent.Output.Add(content);
+ }
+ }
+ }
+
+ private static AIContent ConvertContentBlock(ContentBlock block)
+ {
+ return block switch
+ {
+ TextContentBlock text => new TextContent(text.Text),
+ ImageContentBlock image => CreateDataContentFromBase64(image.Data, image.MimeType ?? "image/*"),
+ AudioContentBlock audio => CreateDataContentFromBase64(audio.Data, audio.MimeType ?? "audio/*"),
+ _ => new TextContent(block.ToString() ?? string.Empty),
+ };
+ }
+
+ private static DataContent CreateDataContentFromBase64(string? base64Data, string mediaType)
+ {
+ if (string.IsNullOrEmpty(base64Data))
+ {
+ return new DataContent($"data:{mediaType};base64,", mediaType);
+ }
+
+ // If it's already a data URI, use it directly
+ if (base64Data.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
+ {
+ return new DataContent(base64Data, mediaType);
+ }
+
+ // Otherwise, construct a data URI from the base64 data
+ return new DataContent($"data:{mediaType};base64,{base64Data}", mediaType);
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj
new file mode 100644
index 0000000000..f9bf706669
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj
@@ -0,0 +1,33 @@
+
+
+
+ true
+ $(NoWarn);MEAI001;OPENAI001
+
+
+
+ true
+ true
+ true
+
+
+
+
+
+
+ Microsoft Agent Framework Declarative Workflows MCP
+ Provides Microsoft Agent Framework support for MCP (Model Context Protocol) server integration in declarative workflows.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs
index c4808f9311..9e421832d4 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs
@@ -20,6 +20,12 @@ public sealed class DeclarativeWorkflowOptions(ResponseAgentProvider agentProvid
///
public ResponseAgentProvider AgentProvider { get; } = Throw.IfNull(agentProvider);
+ ///
+ /// Gets or sets the MCP tool handler for invoking MCP tools within workflows.
+ /// If not set, MCP tool invocations will fail with an appropriate error message.
+ ///
+ public IMcpToolHandler? McpToolHandler { get; init; }
+
///
/// Defines the configuration settings for the workflow.
///
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/JsonDocumentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/JsonDocumentExtensions.cs
index e2b038bbd7..a74b9c6eb8 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/JsonDocumentExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/JsonDocumentExtensions.cs
@@ -42,6 +42,40 @@ internal static class JsonDocumentExtensions
};
}
+ ///
+ /// Creates a VariableType.List with schema inferred from the first object element in the array.
+ ///
+ public static VariableType GetListTypeFromJson(this JsonElement arrayElement)
+ {
+ // Find the first object element to infer schema
+ foreach (JsonElement element in arrayElement.EnumerateArray())
+ {
+ if (element.ValueKind == JsonValueKind.Object)
+ {
+ // Build schema from the object's properties
+ List<(string Key, VariableType Type)> fields = [];
+ foreach (JsonProperty property in element.EnumerateObject())
+ {
+ VariableType fieldType = property.Value.ValueKind switch
+ {
+ JsonValueKind.String => typeof(string),
+ JsonValueKind.Number => typeof(decimal),
+ JsonValueKind.True or JsonValueKind.False => typeof(bool),
+ JsonValueKind.Object => VariableType.RecordType,
+ JsonValueKind.Array => VariableType.ListType,
+ _ => typeof(string),
+ };
+ fields.Add((property.Name, fieldType));
+ }
+
+ return VariableType.List(fields);
+ }
+ }
+
+ // Fallback for arrays of primitives or empty arrays
+ return VariableType.ListType;
+ }
+
private static Dictionary ParseRecord(this JsonElement currentElement, VariableType targetType)
{
IEnumerable> keyValuePairs =
@@ -118,6 +152,7 @@ VariableType DetermineElementType()
JsonValueKind.True => typeof(bool),
JsonValueKind.False => typeof(bool),
JsonValueKind.Number => typeof(decimal),
+ JsonValueKind.Array => (VariableType)VariableType.ListType, // Add support for nested arrays
_ => null,
};
@@ -285,9 +320,16 @@ private static bool TryParseObject(JsonElement propertyElement, VariableType? ta
private static bool TryParseList(JsonElement propertyElement, VariableType? targetType, out object? value)
{
+ // Handle empty arrays without needing to determine element type
+ if (propertyElement.GetArrayLength() == 0)
+ {
+ value = new List