mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Expose workflow as MCP Tool
This commit is contained in:
@@ -76,6 +76,7 @@
|
||||
<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" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/">
|
||||
<File Path="samples/GettingStarted/README.md" />
|
||||
|
||||
+35
@@ -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>
|
||||
+59
@@ -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);
|
||||
+44
@@ -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();
|
||||
+81
@@ -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, delivery estimate) |
|
||||
|
||||
## 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`, `EstimatedDelivery`, and `Status`
|
||||
|
||||
You'll see the workflow executor activities logged in the terminal where you ran `func start`.
|
||||
+20
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,18 @@ 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,39 @@ 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);
|
||||
|
||||
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
|
||||
- Added Azure Functions hosting support for durable workflows ([#4436](https://github.com/microsoft/agent-framework/pull/4436))
|
||||
|
||||
## v1.0.0-preview.251219.1
|
||||
|
||||
@@ -98,4 +98,27 @@ 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)
|
||||
{
|
||||
return new DefaultFunctionMetadata
|
||||
{
|
||||
Name = $"{BuiltInFunctions.McpToolPrefix}{workflowName}",
|
||||
Language = "dotnet-isolated",
|
||||
RawBindings =
|
||||
[
|
||||
$$"""{"name":"context","type":"mcpToolTrigger","direction":"In","toolName":"{{workflowName}}","description":"{{description ?? $"Run the {workflowName} workflow"}}","toolProperties":"[{\"propertyName\":\"input\",\"propertyType\":\"string\",\"description\":\"The input to the workflow.\",\"isRequired\":true,\"isArray\":false}]"}""",
|
||||
"""{"name":"input","type":"mcpToolProperty","direction":"In","propertyName":"input","description":"The input to the workflow","isRequired":true,"dataType":"String","propertyType":"string"}""",
|
||||
"""{"name":"client","type":"durableClient","direction":"In"}"""
|
||||
],
|
||||
EntryPoint = BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint,
|
||||
ScriptFile = BuiltInFunctions.ScriptFile,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -107,7 +107,8 @@ public static class FunctionsApplicationBuilderExtensions
|
||||
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
@@ -27,4 +27,31 @@ public static class DurableWorkflowOptionsExtensions
|
||||
functionsOptions.EnableStatusEndpoint(workflow.Name!);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a workflow and optionally exposes 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!);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
@@ -113,6 +113,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())
|
||||
|
||||
+56
@@ -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,60 @@ 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" } });
|
||||
|
||||
string translateResponse = ((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" } });
|
||||
|
||||
string orderResponse = ((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 ConcurrentWorkflowSampleValidationAsync()
|
||||
{
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
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);
|
||||
|
||||
// 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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user