Skip to content

Commit 49d69b3

Browse files
authored
.NET: Expose workflows as MCP tools when hosting on Azure functions (microsoft#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.
1 parent a9db408 commit 49d69b3

28 files changed

Lines changed: 1011 additions & 39 deletions

dotnet/agent-framework-dotnet.slnx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@
7777
<Project Path="samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/01_SequentialWorkflow.csproj" />
7878
<Project Path="samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/02_ConcurrentWorkflow.csproj" />
7979
<Project Path="samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/03_WorkflowHITL.csproj" />
80+
<Project Path="samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/04_WorkflowMcpTool.csproj" />
81+
<Project Path="samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/05_WorkflowAndAgents.csproj" />
8082
</Folder>
8183
<Folder Name="/Samples/GettingStarted/">
8284
<File Path="samples/GettingStarted/README.md" />
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<TargetFrameworks>net10.0</TargetFrameworks>
4+
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
5+
<OutputType>Exe</OutputType>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<Nullable>enable</Nullable>
8+
<!-- The Functions build tools don't like namespaces that start with a number -->
9+
<AssemblyName>WorkflowMcpTool</AssemblyName>
10+
<RootNamespace>WorkflowMcpTool</RootNamespace>
11+
</PropertyGroup>
12+
13+
<ItemGroup>
14+
<FrameworkReference Include="Microsoft.AspNetCore.App" />
15+
</ItemGroup>
16+
17+
<!-- Azure Functions packages -->
18+
<ItemGroup>
19+
<PackageReference Include="Microsoft.Azure.Functions.Worker" />
20+
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" />
21+
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" />
22+
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" />
23+
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
24+
</ItemGroup>
25+
26+
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
27+
<!--
28+
<ItemGroup>
29+
<PackageReference Include="Microsoft.Agents.AI.Hosting.AzureFunctions" />
30+
</ItemGroup>
31+
-->
32+
<ItemGroup>
33+
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
34+
</ItemGroup>
35+
</Project>
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using Microsoft.Agents.AI.Workflows;
4+
5+
namespace WorkflowMcpTool;
6+
7+
internal sealed class TranslateText() : Executor<string, TranslationResult>("TranslateText")
8+
{
9+
public override ValueTask<TranslationResult> HandleAsync(
10+
string message,
11+
IWorkflowContext context,
12+
CancellationToken cancellationToken = default)
13+
{
14+
Console.WriteLine($"[Activity] TranslateText: '{message}'");
15+
return ValueTask.FromResult(new TranslationResult(message, message.ToUpperInvariant()));
16+
}
17+
}
18+
19+
internal sealed class FormatOutput() : Executor<TranslationResult, string>("FormatOutput")
20+
{
21+
public override ValueTask<string> HandleAsync(
22+
TranslationResult message,
23+
IWorkflowContext context,
24+
CancellationToken cancellationToken = default)
25+
{
26+
Console.WriteLine("[Activity] FormatOutput: Formatting result");
27+
return ValueTask.FromResult($"Original: {message.Original} => Translated: {message.Translated}");
28+
}
29+
}
30+
31+
internal sealed class LookupOrder() : Executor<string, OrderInfo>("LookupOrder")
32+
{
33+
public override ValueTask<OrderInfo> HandleAsync(
34+
string message,
35+
IWorkflowContext context,
36+
CancellationToken cancellationToken = default)
37+
{
38+
Console.WriteLine($"[Activity] LookupOrder: '{message}'");
39+
return ValueTask.FromResult(new OrderInfo(message, "Alice Johnson", "Wireless Headphones", Quantity: 2, UnitPrice: 49.99m));
40+
}
41+
}
42+
43+
internal sealed class EnrichOrder() : Executor<OrderInfo, OrderSummary>("EnrichOrder")
44+
{
45+
public override ValueTask<OrderSummary> HandleAsync(
46+
OrderInfo message,
47+
IWorkflowContext context,
48+
CancellationToken cancellationToken = default)
49+
{
50+
Console.WriteLine($"[Activity] EnrichOrder: '{message.OrderId}'");
51+
return ValueTask.FromResult(new OrderSummary(message, TotalPrice: message.Quantity * message.UnitPrice, Status: "Confirmed"));
52+
}
53+
}
54+
55+
internal sealed record TranslationResult(string Original, string Translated);
56+
57+
internal sealed record OrderInfo(string OrderId, string CustomerName, string Product, int Quantity, decimal UnitPrice);
58+
59+
internal sealed record OrderSummary(OrderInfo Order, decimal TotalPrice, string Status);
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
// This sample demonstrates how to expose a durable workflow as an MCP (Model Context Protocol) tool.
4+
// When using AddWorkflow with exposeMcpToolTrigger: true, the Functions host will automatically
5+
// generate a remote MCP endpoint for the app at /runtime/webhooks/mcp with a workflow-specific
6+
// tool name. MCP-compatible clients can then invoke the workflow as a tool.
7+
8+
using Microsoft.Agents.AI.Hosting.AzureFunctions;
9+
using Microsoft.Agents.AI.Workflows;
10+
using Microsoft.Azure.Functions.Worker.Builder;
11+
using Microsoft.Extensions.Hosting;
12+
using WorkflowMcpTool;
13+
14+
// Define executors
15+
TranslateText translateText = new();
16+
FormatOutput formatOutput = new();
17+
LookupOrder lookupOrder = new();
18+
EnrichOrder enrichOrder = new();
19+
20+
// Build a simple workflow: TranslateText -> FormatOutput
21+
Workflow translateWorkflow = new WorkflowBuilder(translateText)
22+
.WithName("Translate")
23+
.WithDescription("Translate text to uppercase and format the result")
24+
.AddEdge(translateText, formatOutput)
25+
.Build();
26+
27+
// Build a workflow that returns a POCO: LookupOrder -> EnrichOrder
28+
Workflow orderLookupWorkflow = new WorkflowBuilder(lookupOrder)
29+
.WithName("OrderLookup")
30+
.WithDescription("Look up an order by ID and return enriched order details")
31+
.AddEdge(lookupOrder, enrichOrder)
32+
.Build();
33+
34+
using IHost app = FunctionsApplication
35+
.CreateBuilder(args)
36+
.ConfigureFunctionsWebApplication()
37+
.ConfigureDurableWorkflows(workflows =>
38+
{
39+
// Expose both workflows as MCP tool triggers.
40+
workflows.AddWorkflow(translateWorkflow, exposeStatusEndpoint: false, exposeMcpToolTrigger: true);
41+
workflows.AddWorkflow(orderLookupWorkflow, exposeStatusEndpoint: false, exposeMcpToolTrigger: true);
42+
})
43+
.Build();
44+
app.Run();
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Workflow as MCP Tool Sample
2+
3+
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.
4+
5+
## Key Concepts Demonstrated
6+
7+
- **Workflow as MCP Tool**: Expose workflows as callable MCP tools using `exposeMcpToolTrigger: true`
8+
- **MCP Server Hosting**: The Azure Functions host automatically generates a remote MCP endpoint at `/runtime/webhooks/mcp`
9+
- **String and POCO Results**: Shows workflows returning both plain strings and structured JSON objects
10+
11+
## Sample Architecture
12+
13+
The sample creates two workflows exposed as MCP tools:
14+
15+
### Translate Workflow (returns a string)
16+
17+
| Executor | Input | Output | Description |
18+
|----------|-------|--------|-------------|
19+
| **TranslateText** | `string` | `TranslationResult` | Converts input text to uppercase |
20+
| **FormatOutput** | `TranslationResult` | `string` | Formats the result into a readable string |
21+
22+
### OrderLookup Workflow (returns a POCO)
23+
24+
| Executor | Input | Output | Description |
25+
|----------|-------|--------|-------------|
26+
| **LookupOrder** | `string` | `OrderInfo` | Looks up an order by ID |
27+
| **EnrichOrder** | `OrderInfo` | `OrderSummary` | Adds computed fields (total price, status) |
28+
29+
## Environment Setup
30+
31+
See the [README.md](../../README.md) file in the parent directory for complete setup instructions, including:
32+
33+
- Prerequisites installation
34+
- Durable Task Scheduler setup
35+
- Storage emulator configuration
36+
37+
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).
38+
39+
## Running the Sample
40+
41+
1. **Start the Function App**:
42+
43+
```bash
44+
cd dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool
45+
func start
46+
```
47+
48+
2. **Note the MCP Server Endpoint**: When the app starts, you'll see the MCP server endpoint in the terminal output:
49+
50+
```text
51+
MCP server endpoint: http://localhost:7071/runtime/webhooks/mcp
52+
```
53+
54+
## Invoking Workflows via MCP Inspector
55+
56+
1. Install and run the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector):
57+
58+
```bash
59+
npx @modelcontextprotocol/inspector
60+
```
61+
62+
2. Connect to the MCP server endpoint:
63+
- For **Transport Type**, select **"Streamable HTTP"**
64+
- For **URL**, enter `http://localhost:7071/runtime/webhooks/mcp`
65+
- Click the **Connect** button
66+
67+
3. Click the **List Tools** button. You should see two tools: `Translate` and `OrderLookup`.
68+
69+
4. Test the **Translate** tool (returns a plain string):
70+
- Select the `Translate` tool
71+
- Set `hello world` as the `input` parameter
72+
- Click **Run Tool**
73+
- Expected result: `Original: hello world => Translated: HELLO WORLD`
74+
75+
5. Test the **OrderLookup** tool (returns a JSON object):
76+
- Select the `OrderLookup` tool
77+
- Set `ORD-2025-42` as the `input` parameter
78+
- Click **Run Tool**
79+
- Expected result: A JSON object containing order details such as `OrderId`, `CustomerName`, `Product`, `TotalPrice`, and `Status`
80+
81+
You'll see the workflow executor activities logged in the terminal where you ran `func start`.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"version": "2.0",
3+
"logging": {
4+
"logLevel": {
5+
"Microsoft.Agents.AI.DurableTask": "Information",
6+
"Microsoft.Agents.AI.Hosting.AzureFunctions": "Information",
7+
"DurableTask": "Information",
8+
"Microsoft.DurableTask": "Information"
9+
}
10+
},
11+
"extensions": {
12+
"durableTask": {
13+
"hubName": "default",
14+
"storageProvider": {
15+
"type": "AzureManaged",
16+
"connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING"
17+
}
18+
}
19+
}
20+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"IsEncrypted": false,
3+
"Values": {
4+
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
5+
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
6+
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
7+
}
8+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<TargetFrameworks>net10.0</TargetFrameworks>
4+
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
5+
<OutputType>Exe</OutputType>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<Nullable>enable</Nullable>
8+
<!-- The Functions build tools don't like namespaces that start with a number -->
9+
<AssemblyName>WorkflowAndAgents</AssemblyName>
10+
<RootNamespace>WorkflowAndAgents</RootNamespace>
11+
</PropertyGroup>
12+
13+
<ItemGroup>
14+
<FrameworkReference Include="Microsoft.AspNetCore.App" />
15+
</ItemGroup>
16+
17+
<!-- Azure Functions packages -->
18+
<ItemGroup>
19+
<PackageReference Include="Microsoft.Azure.Functions.Worker" />
20+
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" />
21+
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" />
22+
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" />
23+
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
24+
</ItemGroup>
25+
26+
<ItemGroup>
27+
<PackageReference Include="Azure.AI.OpenAI" />
28+
<PackageReference Include="Azure.Identity" />
29+
</ItemGroup>
30+
31+
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
32+
<!--
33+
<ItemGroup>
34+
<PackageReference Include="Microsoft.Agents.AI.Hosting.AzureFunctions" />
35+
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
36+
</ItemGroup>
37+
-->
38+
<ItemGroup>
39+
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
40+
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
41+
</ItemGroup>
42+
</Project>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using Microsoft.Agents.AI.Workflows;
4+
5+
namespace WorkflowAndAgents;
6+
7+
internal sealed class TranslateText() : Executor<string, TranslationResult>("TranslateText")
8+
{
9+
public override ValueTask<TranslationResult> HandleAsync(
10+
string message,
11+
IWorkflowContext context,
12+
CancellationToken cancellationToken = default)
13+
{
14+
Console.WriteLine($"[Activity] TranslateText: '{message}'");
15+
return ValueTask.FromResult(new TranslationResult(message, message.ToUpperInvariant()));
16+
}
17+
}
18+
19+
internal sealed class FormatOutput() : Executor<TranslationResult, string>("FormatOutput")
20+
{
21+
public override ValueTask<string> HandleAsync(
22+
TranslationResult message,
23+
IWorkflowContext context,
24+
CancellationToken cancellationToken = default)
25+
{
26+
Console.WriteLine("[Activity] FormatOutput: Formatting result");
27+
return ValueTask.FromResult($"Original: {message.Original} => Translated: {message.Translated}");
28+
}
29+
}
30+
31+
internal sealed record TranslationResult(string Original, string Translated);

0 commit comments

Comments
 (0)