.NET: Expose workflows as MCP tools when hosting on Azure functions (#4768)

* Expose workflow as MCP Tool

* Expose workflow as MCP Tool

* Cleanup

* PR feedback fixes

* update changelog to include PR numner

* Improvements to error handling.

* Adding a sample project demonstrating how to setup Agents and Workflows together.

* Ensure duplicate agent registrations are properly handled.
This commit is contained in:
Shyju Krishnankutty
2026-03-25 08:43:15 -07:00
committed by GitHub
Unverified
parent a9db40886e
commit 49d69b3bf5
28 changed files with 1011 additions and 39 deletions
+2
View File
@@ -77,6 +77,8 @@
<Project Path="samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/01_SequentialWorkflow.csproj" />
<Project Path="samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/02_ConcurrentWorkflow.csproj" />
<Project Path="samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/03_WorkflowHITL.csproj" />
<Project Path="samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/04_WorkflowMcpTool.csproj" />
<Project Path="samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/05_WorkflowAndAgents.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/">
<File Path="samples/GettingStarted/README.md" />
@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- The Functions build tools don't like namespaces that start with a number -->
<AssemblyName>WorkflowMcpTool</AssemblyName>
<RootNamespace>WorkflowMcpTool</RootNamespace>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<!-- Azure Functions packages -->
<ItemGroup>
<PackageReference Include="Microsoft.Azure.Functions.Worker" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
</ItemGroup>
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
<!--
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Hosting.AzureFunctions" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,59 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace WorkflowMcpTool;
internal sealed class TranslateText() : Executor<string, TranslationResult>("TranslateText")
{
public override ValueTask<TranslationResult> HandleAsync(
string message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine($"[Activity] TranslateText: '{message}'");
return ValueTask.FromResult(new TranslationResult(message, message.ToUpperInvariant()));
}
}
internal sealed class FormatOutput() : Executor<TranslationResult, string>("FormatOutput")
{
public override ValueTask<string> HandleAsync(
TranslationResult message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine("[Activity] FormatOutput: Formatting result");
return ValueTask.FromResult($"Original: {message.Original} => Translated: {message.Translated}");
}
}
internal sealed class LookupOrder() : Executor<string, OrderInfo>("LookupOrder")
{
public override ValueTask<OrderInfo> HandleAsync(
string message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine($"[Activity] LookupOrder: '{message}'");
return ValueTask.FromResult(new OrderInfo(message, "Alice Johnson", "Wireless Headphones", Quantity: 2, UnitPrice: 49.99m));
}
}
internal sealed class EnrichOrder() : Executor<OrderInfo, OrderSummary>("EnrichOrder")
{
public override ValueTask<OrderSummary> HandleAsync(
OrderInfo message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine($"[Activity] EnrichOrder: '{message.OrderId}'");
return ValueTask.FromResult(new OrderSummary(message, TotalPrice: message.Quantity * message.UnitPrice, Status: "Confirmed"));
}
}
internal sealed record TranslationResult(string Original, string Translated);
internal sealed record OrderInfo(string OrderId, string CustomerName, string Product, int Quantity, decimal UnitPrice);
internal sealed record OrderSummary(OrderInfo Order, decimal TotalPrice, string Status);
@@ -0,0 +1,44 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to expose a durable workflow as an MCP (Model Context Protocol) tool.
// When using AddWorkflow with exposeMcpToolTrigger: true, the Functions host will automatically
// generate a remote MCP endpoint for the app at /runtime/webhooks/mcp with a workflow-specific
// tool name. MCP-compatible clients can then invoke the workflow as a tool.
using Microsoft.Agents.AI.Hosting.AzureFunctions;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.Hosting;
using WorkflowMcpTool;
// Define executors
TranslateText translateText = new();
FormatOutput formatOutput = new();
LookupOrder lookupOrder = new();
EnrichOrder enrichOrder = new();
// Build a simple workflow: TranslateText -> FormatOutput
Workflow translateWorkflow = new WorkflowBuilder(translateText)
.WithName("Translate")
.WithDescription("Translate text to uppercase and format the result")
.AddEdge(translateText, formatOutput)
.Build();
// Build a workflow that returns a POCO: LookupOrder -> EnrichOrder
Workflow orderLookupWorkflow = new WorkflowBuilder(lookupOrder)
.WithName("OrderLookup")
.WithDescription("Look up an order by ID and return enriched order details")
.AddEdge(lookupOrder, enrichOrder)
.Build();
using IHost app = FunctionsApplication
.CreateBuilder(args)
.ConfigureFunctionsWebApplication()
.ConfigureDurableWorkflows(workflows =>
{
// Expose both workflows as MCP tool triggers.
workflows.AddWorkflow(translateWorkflow, exposeStatusEndpoint: false, exposeMcpToolTrigger: true);
workflows.AddWorkflow(orderLookupWorkflow, exposeStatusEndpoint: false, exposeMcpToolTrigger: true);
})
.Build();
app.Run();
@@ -0,0 +1,81 @@
# Workflow as MCP Tool Sample
This sample demonstrates how to expose durable workflows as [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) tools, enabling MCP-compatible clients to invoke workflows directly.
## Key Concepts Demonstrated
- **Workflow as MCP Tool**: Expose workflows as callable MCP tools using `exposeMcpToolTrigger: true`
- **MCP Server Hosting**: The Azure Functions host automatically generates a remote MCP endpoint at `/runtime/webhooks/mcp`
- **String and POCO Results**: Shows workflows returning both plain strings and structured JSON objects
## Sample Architecture
The sample creates two workflows exposed as MCP tools:
### Translate Workflow (returns a string)
| Executor | Input | Output | Description |
|----------|-------|--------|-------------|
| **TranslateText** | `string` | `TranslationResult` | Converts input text to uppercase |
| **FormatOutput** | `TranslationResult` | `string` | Formats the result into a readable string |
### OrderLookup Workflow (returns a POCO)
| Executor | Input | Output | Description |
|----------|-------|--------|-------------|
| **LookupOrder** | `string` | `OrderInfo` | Looks up an order by ID |
| **EnrichOrder** | `OrderInfo` | `OrderSummary` | Adds computed fields (total price, status) |
## Environment Setup
See the [README.md](../../README.md) file in the parent directory for complete setup instructions, including:
- Prerequisites installation
- Durable Task Scheduler setup
- Storage emulator configuration
For this sample, you'll also need [Node.js](https://nodejs.org/en/download) to use the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector).
## Running the Sample
1. **Start the Function App**:
```bash
cd dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool
func start
```
2. **Note the MCP Server Endpoint**: When the app starts, you'll see the MCP server endpoint in the terminal output:
```text
MCP server endpoint: http://localhost:7071/runtime/webhooks/mcp
```
## Invoking Workflows via MCP Inspector
1. Install and run the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector):
```bash
npx @modelcontextprotocol/inspector
```
2. Connect to the MCP server endpoint:
- For **Transport Type**, select **"Streamable HTTP"**
- For **URL**, enter `http://localhost:7071/runtime/webhooks/mcp`
- Click the **Connect** button
3. Click the **List Tools** button. You should see two tools: `Translate` and `OrderLookup`.
4. Test the **Translate** tool (returns a plain string):
- Select the `Translate` tool
- Set `hello world` as the `input` parameter
- Click **Run Tool**
- Expected result: `Original: hello world => Translated: HELLO WORLD`
5. Test the **OrderLookup** tool (returns a JSON object):
- Select the `OrderLookup` tool
- Set `ORD-2025-42` as the `input` parameter
- Click **Run Tool**
- Expected result: A JSON object containing order details such as `OrderId`, `CustomerName`, `Product`, `TotalPrice`, and `Status`
You'll see the workflow executor activities logged in the terminal where you ran `func start`.
@@ -0,0 +1,20 @@
{
"version": "2.0",
"logging": {
"logLevel": {
"Microsoft.Agents.AI.DurableTask": "Information",
"Microsoft.Agents.AI.Hosting.AzureFunctions": "Information",
"DurableTask": "Information",
"Microsoft.DurableTask": "Information"
}
},
"extensions": {
"durableTask": {
"hubName": "default",
"storageProvider": {
"type": "AzureManaged",
"connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING"
}
}
}
}
@@ -0,0 +1,8 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
}
}
@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- The Functions build tools don't like namespaces that start with a number -->
<AssemblyName>WorkflowAndAgents</AssemblyName>
<RootNamespace>WorkflowAndAgents</RootNamespace>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<!-- Azure Functions packages -->
<ItemGroup>
<PackageReference Include="Microsoft.Azure.Functions.Worker" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
<!--
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Hosting.AzureFunctions" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,31 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace WorkflowAndAgents;
internal sealed class TranslateText() : Executor<string, TranslationResult>("TranslateText")
{
public override ValueTask<TranslationResult> HandleAsync(
string message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine($"[Activity] TranslateText: '{message}'");
return ValueTask.FromResult(new TranslationResult(message, message.ToUpperInvariant()));
}
}
internal sealed class FormatOutput() : Executor<TranslationResult, string>("FormatOutput")
{
public override ValueTask<string> HandleAsync(
TranslationResult message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine("[Activity] FormatOutput: Formatting result");
return ValueTask.FromResult($"Original: {message.Original} => Translated: {message.Translated}");
}
}
internal sealed record TranslationResult(string Original, string Translated);
@@ -0,0 +1,64 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates using ConfigureDurableOptions to register BOTH agents AND workflows
// in a single Azure Functions app. It uses a workflow to translate text and a standalone AI agent
// accessible via HTTP and MCP tool triggers.
#pragma warning disable IDE0002 // Simplify Member Access
using Azure;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting.AzureFunctions;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.Hosting;
using OpenAI.Chat;
using WorkflowAndAgents;
// Get the Azure OpenAI endpoint and deployment name from environment variables.
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME")
?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set.");
// Use Azure Key Credential if provided, otherwise use Azure CLI Credential.
string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY");
AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey)
? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey))
: new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential());
ChatClient chatClient = client.GetChatClient(deploymentName);
// Define a standalone AI agent
AIAgent assistant = chatClient.AsAIAgent(
"You are a helpful assistant. Answer questions clearly and concisely.",
"Assistant",
description: "A general-purpose helpful assistant.");
// Define workflow executors
TranslateText translateText = new();
FormatOutput formatOutput = new();
// Build a workflow: TranslateText -> FormatOutput
Workflow translateWorkflow = new WorkflowBuilder(translateText)
.WithName("Translate")
.WithDescription("Translate text to uppercase and format the result")
.AddEdge(translateText, formatOutput)
.Build();
// Use ConfigureDurableOptions to register both agents and workflows together
using IHost app = FunctionsApplication
.CreateBuilder(args)
.ConfigureFunctionsWebApplication()
.ConfigureDurableOptions(options =>
{
// Register the standalone agent with HTTP and MCP tool triggers
options.Agents.AddAIAgent(assistant, enableHttpTrigger: true, enableMcpToolTrigger: true);
// Register the workflow with an HTTP endpoint and MCP tool trigger
options.Workflows.AddWorkflow(translateWorkflow, exposeStatusEndpoint: false, exposeMcpToolTrigger: true);
})
.Build();
app.Run();
@@ -0,0 +1,76 @@
# Workflow and Agents Sample
This sample demonstrates how to use `ConfigureDurableOptions` to register **both** AI agents **and** workflows in a single Azure Functions app. This is the recommended approach when your application needs both standalone agents and orchestrated workflows.
## Key Concepts Demonstrated
- **Unified Configuration**: Use `ConfigureDurableOptions` to register agents and workflows together
- **Standalone Agent**: An AI agent accessible via HTTP and MCP tool triggers
- **Workflow**: A simple text translation workflow also exposed as an MCP tool
- **Mixed Triggers**: Both agents and workflows coexist in the same Functions host
## Sample Architecture
### Standalone Agent
| Agent | Description |
|-------|-------------|
| **Assistant** | A general-purpose AI assistant accessible via HTTP (`/agents/Assistant/run`) and as an MCP tool |
### Translate Workflow
| Executor | Input | Output | Description |
|----------|-------|--------|-------------|
| **TranslateText** | `string` | `TranslationResult` | Converts input text to uppercase |
| **FormatOutput** | `TranslationResult` | `string` | Formats the result into a readable string |
## Environment Setup
See the [README.md](../../README.md) file in the parent directory for complete setup instructions, including:
- Prerequisites installation
- Durable Task Scheduler setup
- Storage emulator configuration
This sample also requires Azure OpenAI credentials. Set the following in `local.settings.json`:
- `AZURE_OPENAI_ENDPOINT`: Your Azure OpenAI endpoint URL
- `AZURE_OPENAI_DEPLOYMENT_NAME`: Your chat model deployment name
- `AZURE_OPENAI_API_KEY` (optional): If not set, Azure CLI credential is used
## Running the Sample
1. **Start the Function App**:
```bash
cd dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents
func start
```
2. **Expected Functions**: When the app starts, you should see functions for both the agent and the workflow:
- `dafx-Assistant` (entity trigger for the agent)
- `http-Assistant` (HTTP trigger for the agent)
- `mcptool-Assistant` (MCP tool trigger for the agent)
- `wf-Translate` (orchestration trigger for the workflow)
- `mcptool-wf-Translate` (MCP tool trigger for the workflow)
## Invoking the Agent via HTTP
```bash
curl -X POST http://localhost:7071/agents/Assistant/run \
-H "Content-Type: application/json" \
-d '{"query": "What is the capital of France?"}'
```
## Invoking via MCP Inspector
1. Install and run the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector):
```bash
npx @modelcontextprotocol/inspector
```
2. Connect to `http://localhost:7071/runtime/webhooks/mcp` using **Streamable HTTP** transport.
3. Click **List Tools** to see both the `Assistant` agent tool and the `Translate` workflow tool.
@@ -0,0 +1,20 @@
{
"version": "2.0",
"logging": {
"logLevel": {
"Microsoft.Agents.AI.DurableTask": "Information",
"Microsoft.Agents.AI.Hosting.AzureFunctions": "Information",
"DurableTask": "Information",
"Microsoft.DurableTask": "Information"
}
},
"extensions": {
"durableTask": {
"hubName": "default",
"storageProvider": {
"type": "AzureManaged",
"connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING"
}
}
}
}
@@ -0,0 +1,10 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -48,3 +48,4 @@ $env:DURABLE_TASK_SCHEDULER_CONNECTION_STRING = "AccountEndpoint=http://localhos
| [01_SequentialWorkflow](AzureFunctions/01_SequentialWorkflow/) | Sequential workflow hosted in Azure Functions |
| [02_ConcurrentWorkflow](AzureFunctions/02_ConcurrentWorkflow/) | Concurrent workflow hosted in Azure Functions |
| [03_WorkflowHITL](AzureFunctions/03_WorkflowHITL/) | Human-in-the-loop workflow hosted in Azure Functions |
| [04_WorkflowMcpTool](AzureFunctions/04_WorkflowMcpTool/) | Workflow exposed as an MCP tool |
@@ -167,6 +167,20 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
return;
}
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint)
{
if (mcpToolInvocationContext is null)
{
throw new InvalidOperationException($"MCP tool invocation context binding is missing for the invocation {context.InvocationId}.");
}
context.GetInvocationResult().Value = await BuiltInFunctions.RunWorkflowMcpToolAsync(
mcpToolInvocationContext,
durableTaskClient,
context);
return;
}
throw new InvalidOperationException($"Unsupported function entry point '{context.FunctionDefinition.EntryPoint}' for invocation {context.InvocationId}.");
}
@@ -29,6 +29,7 @@ internal static class BuiltInFunctions
internal static readonly string InvokeWorkflowActivityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeWorkflowActivityAsync)}";
internal static readonly string GetWorkflowStatusHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(GetWorkflowStatusAsync)}";
internal static readonly string RespondToWorkflowHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RespondToWorkflowAsync)}";
internal static readonly string RunWorkflowMcpToolFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunWorkflowMcpToolAsync)}";
#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file - Azure Functions does not use single-file publishing
internal static readonly string ScriptFile = Path.GetFileName(typeof(BuiltInFunctions).Assembly.Location);
@@ -378,6 +379,55 @@ internal static class BuiltInFunctions
return agentResponse.Text;
}
/// <summary>
/// Runs a workflow via MCP tool trigger.
/// Extracts the <c>input</c> argument, schedules a new orchestration, waits for completion, and returns the output.
/// </summary>
public static async Task<string?> RunWorkflowMcpToolAsync(
[McpToolTrigger("BuiltInWorkflowMcpTool")] ToolInvocationContext context,
[DurableClient] DurableTaskClient client,
FunctionContext functionContext)
{
if (context.Arguments is null)
{
throw new ArgumentException("MCP Tool invocation is missing required arguments.");
}
if (!context.Arguments.TryGetValue("input", out object? inputObj) || inputObj is not string input)
{
throw new ArgumentException("MCP Tool invocation is missing required 'input' argument of type string.");
}
string workflowName = context.Name;
string orchestrationFunctionName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflowName);
DurableWorkflowInput<string> orchestrationInput = new() { Input = input };
string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(orchestrationFunctionName, orchestrationInput);
OrchestrationMetadata? metadata = await client.WaitForInstanceCompletionAsync(
instanceId,
getInputsAndOutputs: true,
cancellation: functionContext.CancellationToken);
if (metadata is null)
{
throw new InvalidOperationException($"Workflow orchestration '{instanceId}' returned no metadata.");
}
if (metadata.RuntimeStatus is OrchestrationRuntimeStatus.Failed)
{
string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Unknown error";
throw new InvalidOperationException($"Workflow orchestration '{instanceId}' failed: {errorMessage}");
}
if (metadata.RuntimeStatus is not OrchestrationRuntimeStatus.Completed)
{
throw new InvalidOperationException($"Workflow orchestration '{instanceId}' ended with unexpected status '{metadata.RuntimeStatus}'.");
}
return metadata.ReadOutputAs<DurableWorkflowResult>()?.Result;
}
/// <summary>
/// Creates an error response with the specified status code and error message.
/// </summary>
@@ -2,6 +2,7 @@
## [Unreleased]
- Added MCP tool trigger support for durable workflows ([#4768](https://github.com/microsoft/agent-framework/pull/4768))
- Added Azure Functions hosting support for durable workflows ([#4436](https://github.com/microsoft/agent-framework/pull/4436))
## v1.0.0-preview.251219.1
@@ -6,7 +6,8 @@ namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
/// <summary>
/// Provides access to agent-specific options for functions agents by name.
/// Returns default options (HTTP trigger enabled, MCP tool disabled) when no explicit options were configured.
/// Returns <see langword="false"/> when no explicit options have been configured for an agent,
/// which distinguishes standalone agents from those auto-registered by workflows.
/// </summary>
internal sealed class DefaultFunctionsAgentOptionsProvider(IReadOnlyDictionary<string, FunctionsAgentOptions> functionsAgentOptions)
: IFunctionsAgentOptionsProvider
@@ -14,32 +15,19 @@ internal sealed class DefaultFunctionsAgentOptionsProvider(IReadOnlyDictionary<s
private readonly IReadOnlyDictionary<string, FunctionsAgentOptions> _functionsAgentOptions =
functionsAgentOptions ?? throw new ArgumentNullException(nameof(functionsAgentOptions));
// Default options. HTTP trigger enabled, MCP tool disabled.
private static readonly FunctionsAgentOptions s_defaultOptions = new()
{
HttpTrigger = { IsEnabled = true },
McpToolTrigger = { IsEnabled = false }
};
/// <summary>
/// Attempts to retrieve the options associated with the specified agent name.
/// If not found, a default options instance (with HTTP trigger enabled) is returned.
/// Returns <see langword="false"/> when no options have been explicitly configured for the agent.
/// </summary>
/// <param name="agentName">The name of the agent whose options are to be retrieved. Cannot be null or empty.</param>
/// <param name="options">The options for the specified agent. Will never be null.</param>
/// <returns>Always true. Returns configured options if present; otherwise default fallback options.</returns>
/// <param name="options">
/// When this method returns <see langword="true"/>, contains the options for the specified agent;
/// otherwise, <see langword="null"/>.
/// </param>
/// <returns><see langword="true"/> if options were found for the agent; otherwise, <see langword="false"/>.</returns>
public bool TryGet(string agentName, [NotNullWhen(true)] out FunctionsAgentOptions? options)
{
ArgumentException.ThrowIfNullOrEmpty(agentName);
if (this._functionsAgentOptions.TryGetValue(agentName, out FunctionsAgentOptions? existing))
{
options = existing;
return true;
}
// If not defined, return default options.
options = s_defaultOptions;
return true;
return this._functionsAgentOptions.TryGetValue(agentName, out options);
}
}
@@ -6,9 +6,13 @@ using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
/// <summary>
/// Transforms function metadata by registering durable agent functions for each configured agent.
/// Transforms function metadata by registering durable agent functions for each explicitly configured agent.
/// </summary>
/// <remarks>This transformer adds both entity trigger and HTTP trigger functions for every agent registered in the application.</remarks>
/// <remarks>
/// This transformer adds entity, HTTP, and MCP tool trigger functions for agents that have
/// explicit <see cref="FunctionsAgentOptions"/>. Agents auto-registered by workflows
/// (which lack explicit options) are handled by <see cref="DurableWorkflowsFunctionMetadataTransformer"/>.
/// </remarks>
internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadataTransformer
{
private readonly ILogger<DurableAgentFunctionMetadataTransformer> _logger;
@@ -38,24 +42,27 @@ internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadat
{
string agentName = kvp.Key;
this._logger.LogRegisteringTriggerForAgent(agentName, "entity");
// Only generate triggers for agents with explicit Functions agent options.
// Agents auto-registered by workflows are handled by DurableWorkflowsFunctionMetadataTransformer.
if (!this._functionsAgentOptionsProvider.TryGet(agentName, out FunctionsAgentOptions? agentTriggerOptions))
{
continue;
}
this._logger.LogRegisteringTriggerForAgent(agentName, "entity");
original.Add(FunctionMetadataFactory.CreateEntityTrigger(agentName));
if (this._functionsAgentOptionsProvider.TryGet(agentName, out FunctionsAgentOptions? agentTriggerOptions))
if (agentTriggerOptions.HttpTrigger.IsEnabled)
{
if (agentTriggerOptions.HttpTrigger.IsEnabled)
{
this._logger.LogRegisteringTriggerForAgent(agentName, "http");
original.Add(FunctionMetadataFactory.CreateHttpTrigger(agentName, $"agents/{agentName}/run", BuiltInFunctions.RunAgentHttpFunctionEntryPoint));
}
this._logger.LogRegisteringTriggerForAgent(agentName, "http");
original.Add(FunctionMetadataFactory.CreateHttpTrigger(agentName, $"agents/{agentName}/run", BuiltInFunctions.RunAgentHttpFunctionEntryPoint));
}
if (agentTriggerOptions.McpToolTrigger.IsEnabled)
{
AIAgent agent = kvp.Value(this._serviceProvider);
this._logger.LogRegisteringTriggerForAgent(agentName, "mcpTool");
original.Add(CreateMcpToolTrigger(agentName, agent.Description));
}
if (agentTriggerOptions.McpToolTrigger.IsEnabled)
{
AIAgent agent = kvp.Value(this._serviceProvider);
this._logger.LogRegisteringTriggerForAgent(agentName, "mcpTool");
original.Add(CreateMcpToolTrigger(agentName, agent.Description));
}
}
}
@@ -134,4 +134,17 @@ public static class DurableAgentsOptionsExtensions
{
return new Dictionary<string, FunctionsAgentOptions>(s_agentOptions, StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Ensures every agent in <paramref name="agentNames"/> has an entry in the
/// options registry. Agents that already have explicit options are left untouched.
/// New entries receive the default configuration (HTTP trigger enabled, MCP tool disabled).
/// </summary>
internal static void EnsureDefaultOptionsForAll(IEnumerable<string> agentNames)
{
foreach (string name in agentNames)
{
s_agentOptions.TryAdd(name, new FunctionsAgentOptions { HttpTrigger = { IsEnabled = true } });
}
}
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Nodes;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
@@ -98,4 +99,65 @@ internal static class FunctionMetadataFactory
ScriptFile = BuiltInFunctions.ScriptFile,
};
}
/// <summary>
/// Creates function metadata for an MCP tool trigger function that starts a workflow.
/// </summary>
/// <param name="workflowName">The name of the workflow to expose as an MCP tool.</param>
/// <param name="description">An optional description for the MCP tool. If null, a default description is generated.</param>
/// <returns>A <see cref="DefaultFunctionMetadata"/> configured for an MCP tool trigger.</returns>
internal static DefaultFunctionMetadata CreateWorkflowMcpToolTrigger(
string workflowName,
string? description)
{
var functionName = $"{BuiltInFunctions.McpToolPrefix}{workflowName}";
var toolDescription = description ?? $"Run the {workflowName} workflow";
var toolProperties = new JsonArray(new JsonObject
{
["propertyName"] = "input",
["propertyType"] = "string",
["description"] = "The input to the workflow.",
["isRequired"] = true,
["isArray"] = false,
});
var triggerBinding = new JsonObject
{
["name"] = "context",
["type"] = "mcpToolTrigger",
["direction"] = "In",
["toolName"] = workflowName,
["description"] = toolDescription,
["toolProperties"] = toolProperties.ToJsonString(),
};
var inputBinding = new JsonObject
{
["name"] = "input",
["type"] = "mcpToolProperty",
["direction"] = "In",
["propertyName"] = "input",
["description"] = "The input to the workflow",
["isRequired"] = true,
["dataType"] = "String",
["propertyType"] = "string",
};
var clientBinding = new JsonObject
{
["name"] = "client",
["type"] = "durableClient",
["direction"] = "In",
};
return new DefaultFunctionMetadata
{
Name = functionName,
Language = "dotnet-isolated",
RawBindings = [triggerBinding.ToJsonString(), inputBinding.ToJsonString(), clientBinding.ToJsonString()],
EntryPoint = BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint,
ScriptFile = BuiltInFunctions.ScriptFile,
};
}
}
@@ -27,9 +27,16 @@ public static class FunctionsApplicationBuilderExtensions
{
ArgumentNullException.ThrowIfNull(configure);
// Create/get shared options BEFORE the DurableTask library call so it can find them.
FunctionsDurableOptions sharedOptions = GetOrCreateSharedOptions(builder.Services);
// The main agent services registration is done in Microsoft.DurableTask.Agents.
builder.Services.ConfigureDurableAgents(configure);
// Ensure all agents registered through this path have default FunctionsAgentOptions.
// This distinguishes them from agents auto-registered by workflows.
DurableAgentsOptionsExtensions.EnsureDefaultOptionsForAll(sharedOptions.Agents.GetAgentFactories().Keys);
builder.Services.TryAddSingleton<IFunctionsAgentOptionsProvider>(_ =>
new DefaultFunctionsAgentOptionsProvider(DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot()));
@@ -67,6 +74,13 @@ public static class FunctionsApplicationBuilderExtensions
builder.Services.ConfigureDurableOptions(configure);
if (DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot().Count > 0)
{
builder.Services.TryAddSingleton<IFunctionsAgentOptionsProvider>(_ =>
new DefaultFunctionsAgentOptionsProvider(DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot()));
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IFunctionMetadataTransformer, DurableAgentFunctionMetadataTransformer>());
}
if (sharedOptions.Workflows.Workflows.Count > 0)
{
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IFunctionMetadataTransformer, DurableWorkflowsFunctionMetadataTransformer>());
@@ -102,12 +116,14 @@ public static class FunctionsApplicationBuilderExtensions
builder.UseWhen<BuiltInFunctionExecutionMiddleware>(static context =>
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentHttpFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentMcpToolFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RespondToWorkflowHttpFunctionEntryPoint, StringComparison.Ordinal)
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RespondToWorkflowHttpFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint, StringComparison.Ordinal)
);
builder.Services.TryAddSingleton<BuiltInFunctionExecutor>();
}
@@ -10,6 +10,7 @@ namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
internal sealed class FunctionsDurableOptions : DurableOptions
{
private readonly HashSet<string> _statusEndpointWorkflows = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _mcpToolTriggerWorkflows = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Enables the status HTTP endpoint for the specified workflow.
@@ -26,4 +27,20 @@ internal sealed class FunctionsDurableOptions : DurableOptions
{
return this._statusEndpointWorkflows.Contains(workflowName);
}
/// <summary>
/// Enables the MCP tool trigger for the specified workflow.
/// </summary>
internal void EnableMcpToolTrigger(string workflowName)
{
this._mcpToolTriggerWorkflows.Add(workflowName);
}
/// <summary>
/// Returns whether the MCP tool trigger is enabled for the specified workflow.
/// </summary>
internal bool IsMcpToolTriggerEnabled(string workflowName)
{
return this._mcpToolTriggerWorkflows.Contains(workflowName);
}
}
@@ -27,4 +27,31 @@ public static class DurableWorkflowOptionsExtensions
functionsOptions.EnableStatusEndpoint(workflow.Name!);
}
}
/// <summary>
/// Adds a workflow and configures whether to expose a status HTTP endpoint and/or an MCP tool trigger.
/// </summary>
/// <param name="options">The workflow options to add the workflow to.</param>
/// <param name="workflow">The workflow instance to add.</param>
/// <param name="exposeStatusEndpoint">If <see langword="true"/>, a GET endpoint is generated at <c>workflows/{name}/status/{runId}</c>.</param>
/// <param name="exposeMcpToolTrigger">If <see langword="true"/>, an MCP tool trigger is generated for the workflow.</param>
public static void AddWorkflow(this DurableWorkflowOptions options, Workflow workflow, bool exposeStatusEndpoint, bool exposeMcpToolTrigger)
{
ArgumentNullException.ThrowIfNull(options);
options.AddWorkflow(workflow);
if (options.ParentOptions is FunctionsDurableOptions functionsOptions)
{
if (exposeStatusEndpoint)
{
functionsOptions.EnableStatusEndpoint(workflow.Name!);
}
if (exposeMcpToolTrigger)
{
functionsOptions.EnableMcpToolTrigger(workflow.Name!);
}
}
}
}
@@ -50,8 +50,11 @@ internal sealed class DurableWorkflowsFunctionMetadataTransformer : IFunctionMet
int initialCount = original.Count;
this._logger.LogTransformingFunctionMetadata(initialCount);
// Track registered function names to avoid duplicates when workflows share executors.
HashSet<string> registeredFunctions = [];
// Seed with existing function names to avoid duplicates across transformers
// (e.g., when DurableAgentFunctionMetadataTransformer already registered entity triggers).
HashSet<string> registeredFunctions = new(
original.Select(f => f.Name!),
StringComparer.OrdinalIgnoreCase);
DurableWorkflowOptions workflowOptions = this._options.Workflows;
foreach (var workflow in workflowOptions.Workflows)
@@ -113,6 +116,17 @@ internal sealed class DurableWorkflowsFunctionMetadataTransformer : IFunctionMet
}
}
// Register an MCP tool trigger if opted in via AddWorkflow(exposeMcpToolTrigger: true).
if (this._options.IsMcpToolTriggerEnabled(workflow.Key))
{
string mcpToolFunctionName = $"{BuiltInFunctions.McpToolPrefix}{workflow.Key}";
if (registeredFunctions.Add(mcpToolFunctionName))
{
this._logger.LogRegisteringWorkflowTrigger(workflow.Key, mcpToolFunctionName, "mcpTool");
original.Add(FunctionMetadataFactory.CreateWorkflowMcpToolTrigger(workflow.Key, workflow.Value.Description));
}
}
// Register activity or entity functions for each executor in the workflow.
// ReflectExecutors() returns all executors across the graph; no need to manually traverse edges.
foreach (KeyValuePair<string, ExecutorBinding> entry in workflow.Value.ReflectExecutors())
@@ -5,6 +5,8 @@ using System.Reflection;
using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests;
/// <summary>
@@ -235,6 +237,114 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
});
}
[Fact]
public async Task WorkflowMcpToolSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "04_WorkflowMcpTool");
await this.RunSampleTestAsync(samplePath, requiresOpenAI: false, async (logs) =>
{
// Connect to the MCP endpoint exposed by the Azure Functions host
IClientTransport clientTransport = new HttpClientTransport(new()
{
Endpoint = new Uri($"http://localhost:{AzureFunctionsPort}/runtime/webhooks/mcp")
});
await using McpClient mcpClient = await McpClient.CreateAsync(clientTransport);
// Verify both workflow tools are listed
IList<McpClientTool> tools = await mcpClient.ListToolsAsync();
this._outputHelper.WriteLine($"MCP tools found: {string.Join(", ", tools.Select(t => t.Name))}");
Assert.Single(tools, t => t.Name == "Translate");
Assert.Single(tools, t => t.Name == "OrderLookup");
// Invoke the Translate workflow via MCP tool (returns a string result)
this._outputHelper.WriteLine("Invoking MCP tool 'Translate'...");
CallToolResult translateResult = await mcpClient.CallToolAsync(
"Translate",
arguments: new Dictionary<string, object?> { { "input", "hello world" } });
Assert.NotEmpty(translateResult.Content);
string translateResponse = Assert.IsType<TextContentBlock>(translateResult.Content[0]).Text;
this._outputHelper.WriteLine($"Translate MCP tool response: {translateResponse}");
Assert.NotEmpty(translateResponse);
Assert.Contains("HELLO WORLD", translateResponse);
// Invoke the OrderLookup workflow via MCP tool (returns a POCO serialized as JSON)
this._outputHelper.WriteLine("Invoking MCP tool 'OrderLookup'...");
CallToolResult orderResult = await mcpClient.CallToolAsync(
"OrderLookup",
arguments: new Dictionary<string, object?> { { "input", "ORD-2025-42" } });
Assert.NotEmpty(orderResult.Content);
string orderResponse = Assert.IsType<TextContentBlock>(orderResult.Content[0]).Text;
this._outputHelper.WriteLine($"OrderLookup MCP tool response: {orderResponse}");
Assert.NotEmpty(orderResponse);
Assert.Contains("ORD-2025-42", orderResponse);
// Verify executor activities ran in the logs
lock (logs)
{
Assert.True(logs.Any(log => log.Message.Contains("[Activity] TranslateText:")), "TranslateText activity not found in logs.");
Assert.True(logs.Any(log => log.Message.Contains("[Activity] FormatOutput:")), "FormatOutput activity not found in logs.");
Assert.True(logs.Any(log => log.Message.Contains("[Activity] LookupOrder:")), "LookupOrder activity not found in logs.");
Assert.True(logs.Any(log => log.Message.Contains("[Activity] EnrichOrder:")), "EnrichOrder activity not found in logs.");
}
});
}
[Fact]
public async Task WorkflowAndAgentsSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "05_WorkflowAndAgents");
await this.RunSampleTestAsync(samplePath, requiresOpenAI: true, async (logs) =>
{
// Connect to the MCP endpoint exposed by the Azure Functions host
IClientTransport clientTransport = new HttpClientTransport(new()
{
Endpoint = new Uri($"http://localhost:{AzureFunctionsPort}/runtime/webhooks/mcp")
});
await using McpClient mcpClient = await McpClient.CreateAsync(clientTransport);
// Verify both the agent and workflow tools are listed
IList<McpClientTool> tools = await mcpClient.ListToolsAsync();
this._outputHelper.WriteLine($"MCP tools found: {string.Join(", ", tools.Select(t => t.Name))}");
Assert.Single(tools, t => t.Name == "Assistant");
Assert.Single(tools, t => t.Name == "Translate");
// Invoke the Translate workflow via MCP tool
this._outputHelper.WriteLine("Invoking MCP tool 'Translate'...");
CallToolResult translateResult = await mcpClient.CallToolAsync(
"Translate",
arguments: new Dictionary<string, object?> { { "input", "hello world" } });
Assert.NotEmpty(translateResult.Content);
string translateResponse = Assert.IsType<TextContentBlock>(translateResult.Content[0]).Text;
this._outputHelper.WriteLine($"Translate MCP tool response: {translateResponse}");
Assert.Contains("HELLO WORLD", translateResponse);
// Invoke the Assistant agent via MCP tool
this._outputHelper.WriteLine("Invoking MCP tool 'Assistant'...");
CallToolResult assistantResult = await mcpClient.CallToolAsync(
"Assistant",
arguments: new Dictionary<string, object?> { { "query", "What is 2 + 2?" } });
Assert.NotEmpty(assistantResult.Content);
string assistantResponse = Assert.IsType<TextContentBlock>(assistantResult.Content[0]).Text;
this._outputHelper.WriteLine($"Assistant MCP tool response: {assistantResponse}");
Assert.NotEmpty(assistantResponse);
// Verify workflow executor activities ran in the logs
lock (logs)
{
Assert.True(logs.Any(log => log.Message.Contains("[Activity] TranslateText:")), "TranslateText activity not found in logs.");
Assert.True(logs.Any(log => log.Message.Contains("[Activity] FormatOutput:")), "FormatOutput activity not found in logs.");
}
});
}
[Fact]
public async Task ConcurrentWorkflowSampleValidationAsync()
{
@@ -148,6 +148,45 @@ public sealed class DurableAgentFunctionMetadataTransformerTests
}
}
[Fact]
public void Transform_SkipsAgents_WithoutExplicitOptions()
{
// Arrange: two agents in the dictionary, but only one has explicit FunctionsAgentOptions.
// This simulates a workflow-auto-registered agent (workflowAgent) alongside a standalone agent.
Dictionary<string, Func<IServiceProvider, AIAgent>> agents = new()
{
{ "standaloneAgent", _ => new TestAgent("standaloneAgent", "Standalone agent") },
{ "workflowAgent", _ => new TestAgent("workflowAgent", "Auto-registered by workflow") }
};
FunctionsAgentOptions standaloneOptions = new();
standaloneOptions.HttpTrigger.IsEnabled = true;
// Only standaloneAgent has explicit options; workflowAgent does not.
IFunctionsAgentOptionsProvider agentOptionsProvider = new FakeOptionsProvider(new Dictionary<string, FunctionsAgentOptions>
{
{ "standaloneAgent", standaloneOptions }
});
List<IFunctionMetadata> metadataList = [];
DurableAgentFunctionMetadataTransformer transformer = new(
agents,
NullLogger<DurableAgentFunctionMetadataTransformer>.Instance,
new FakeServiceProvider(),
agentOptionsProvider);
// Act
transformer.Transform(metadataList);
// Assert: only standaloneAgent should have triggers (entity + http = 2).
// workflowAgent should be skipped entirely.
Assert.Equal(2, metadataList.Count);
Assert.Contains(metadataList, m => m.Name == "dafx-standaloneAgent");
Assert.Contains(metadataList, m => m.Name == "http-standaloneAgent");
Assert.DoesNotContain(metadataList, m => m.Name!.Contains("workflowAgent"));
}
private static List<IFunctionMetadata> BuildFunctionMetadataList(int numberOfFunctions)
{
List<IFunctionMetadata> list = [];
@@ -0,0 +1,121 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests;
public sealed class FunctionMetadataFactoryTests
{
[Fact]
public void CreateEntityTrigger_SetsCorrectNameAndBindings()
{
DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateEntityTrigger("myAgent");
Assert.Equal("dafx-myAgent", metadata.Name);
Assert.Equal("dotnet-isolated", metadata.Language);
Assert.Equal(BuiltInFunctions.RunAgentEntityFunctionEntryPoint, metadata.EntryPoint);
Assert.NotNull(metadata.RawBindings);
Assert.Equal(2, metadata.RawBindings.Count);
Assert.Contains("entityTrigger", metadata.RawBindings[0]);
Assert.Contains("durableClient", metadata.RawBindings[1]);
}
[Fact]
public void CreateHttpTrigger_SetsCorrectNameRouteAndDefaults()
{
DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateHttpTrigger(
"myWorkflow", "workflows/myWorkflow/run", BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint);
Assert.Equal("http-myWorkflow", metadata.Name);
Assert.Equal("dotnet-isolated", metadata.Language);
Assert.Equal(BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint, metadata.EntryPoint);
Assert.NotNull(metadata.RawBindings);
Assert.Equal(3, metadata.RawBindings.Count);
Assert.Contains("httpTrigger", metadata.RawBindings[0]);
Assert.Contains("workflows/myWorkflow/run", metadata.RawBindings[0]);
Assert.Contains("\"post\"", metadata.RawBindings[0]);
Assert.Contains("http", metadata.RawBindings[1]);
Assert.Contains("durableClient", metadata.RawBindings[2]);
}
[Fact]
public void CreateHttpTrigger_RespectsCustomMethods()
{
DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateHttpTrigger(
"status", "workflows/status/{runId}", BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint, methods: "\"get\"");
Assert.NotNull(metadata.RawBindings);
Assert.Contains("\"get\"", metadata.RawBindings[0]);
Assert.DoesNotContain("\"post\"", metadata.RawBindings[0]);
}
[Fact]
public void CreateActivityTrigger_SetsCorrectNameAndBindings()
{
DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateActivityTrigger("dafx-MyExecutor");
Assert.Equal("dafx-MyExecutor", metadata.Name);
Assert.Equal("dotnet-isolated", metadata.Language);
Assert.Equal(BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, metadata.EntryPoint);
Assert.NotNull(metadata.RawBindings);
Assert.Equal(2, metadata.RawBindings.Count);
Assert.Contains("activityTrigger", metadata.RawBindings[0]);
Assert.Contains("durableClient", metadata.RawBindings[1]);
}
[Fact]
public void CreateOrchestrationTrigger_SetsCorrectNameAndBindings()
{
DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateOrchestrationTrigger(
"dafx-MyWorkflow", BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint);
Assert.Equal("dafx-MyWorkflow", metadata.Name);
Assert.Equal("dotnet-isolated", metadata.Language);
Assert.Equal(BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint, metadata.EntryPoint);
Assert.NotNull(metadata.RawBindings);
Assert.Single(metadata.RawBindings);
Assert.Contains("orchestrationTrigger", metadata.RawBindings[0]);
}
[Fact]
public void CreateWorkflowMcpToolTrigger_SetsCorrectNameAndBindings()
{
DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateWorkflowMcpToolTrigger("Translate", "Translate text");
Assert.Equal("mcptool-Translate", metadata.Name);
Assert.Equal("dotnet-isolated", metadata.Language);
Assert.Equal(BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint, metadata.EntryPoint);
Assert.NotNull(metadata.RawBindings);
Assert.Equal(3, metadata.RawBindings.Count);
// Verify all bindings are valid JSON
foreach (string binding in metadata.RawBindings)
{
JsonDocument.Parse(binding);
}
// mcpToolTrigger binding
Assert.Contains("mcpToolTrigger", metadata.RawBindings[0]);
Assert.Contains("\"toolName\":\"Translate\"", metadata.RawBindings[0]);
Assert.Contains("\"description\":\"Translate text\"", metadata.RawBindings[0]);
Assert.Contains("toolProperties", metadata.RawBindings[0]);
// mcpToolProperty binding for input
Assert.Contains("mcpToolProperty", metadata.RawBindings[1]);
Assert.Contains("\"propertyName\":\"input\"", metadata.RawBindings[1]);
Assert.Contains("\"isRequired\":true", metadata.RawBindings[1]);
// durableClient binding
Assert.Contains("durableClient", metadata.RawBindings[2]);
}
[Fact]
public void CreateWorkflowMcpToolTrigger_UsesDefaultDescription_WhenNull()
{
DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateWorkflowMcpToolTrigger("MyWorkflow", description: null);
Assert.NotNull(metadata.RawBindings);
Assert.Contains("Run the MyWorkflow workflow", metadata.RawBindings[0]);
}
}