diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 576d2c5c54..ff815348c7 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -76,6 +76,8 @@ + + diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/04_WorkflowMcpTool.csproj b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/04_WorkflowMcpTool.csproj new file mode 100644 index 0000000000..68f9ccb801 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/04_WorkflowMcpTool.csproj @@ -0,0 +1,35 @@ + + + net10.0 + v4 + Exe + enable + enable + + WorkflowMcpTool + WorkflowMcpTool + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/Executors.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/Executors.cs new file mode 100644 index 0000000000..0621680e33 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/Executors.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace WorkflowMcpTool; + +internal sealed class TranslateText() : Executor("TranslateText") +{ + public override ValueTask 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("FormatOutput") +{ + public override ValueTask 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("LookupOrder") +{ + public override ValueTask 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("EnrichOrder") +{ + public override ValueTask 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); diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/Program.cs new file mode 100644 index 0000000000..0970ca16b8 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/Program.cs @@ -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(); diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/README.md b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/README.md new file mode 100644 index 0000000000..a5411bf375 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/README.md @@ -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`. diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/host.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/host.json new file mode 100644 index 0000000000..9384a0a583 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/host.json @@ -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" + } + } + } +} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/local.settings.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/local.settings.json new file mode 100644 index 0000000000..fcb6658e92 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/local.settings.json @@ -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" + } +} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/05_WorkflowAndAgents.csproj b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/05_WorkflowAndAgents.csproj new file mode 100644 index 0000000000..517dd323a7 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/05_WorkflowAndAgents.csproj @@ -0,0 +1,42 @@ + + + net10.0 + v4 + Exe + enable + enable + + WorkflowAndAgents + WorkflowAndAgents + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Executors.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Executors.cs new file mode 100644 index 0000000000..727379b482 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Executors.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace WorkflowAndAgents; + +internal sealed class TranslateText() : Executor("TranslateText") +{ + public override ValueTask 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("FormatOutput") +{ + public override ValueTask 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); diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Program.cs new file mode 100644 index 0000000000..51b9fb4d7f --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Program.cs @@ -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(); diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/README.md b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/README.md new file mode 100644 index 0000000000..37841777cc --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/README.md @@ -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. diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/host.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/host.json new file mode 100644 index 0000000000..9384a0a583 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/host.json @@ -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" + } + } + } +} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/local.settings.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/local.settings.json new file mode 100644 index 0000000000..5f6d7d3340 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/local.settings.json @@ -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_DEPLOYMENT_NAME": "" + } +} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/README.md b/dotnet/samples/04-hosting/DurableWorkflows/README.md index 2b7103de50..f2386380d7 100644 --- a/dotnet/samples/04-hosting/DurableWorkflows/README.md +++ b/dotnet/samples/04-hosting/DurableWorkflows/README.md @@ -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 | diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs index 8239ff17cc..64ec846eb8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs @@ -167,6 +167,20 @@ public async ValueTask ExecuteAsync(FunctionContext context) 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}."); } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs index 6dc1ab2244..e6c94347a1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs @@ -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 @@ await agentProxy.RunAsync( return agentResponse.Text; } + /// + /// Runs a workflow via MCP tool trigger. + /// Extracts the input argument, schedules a new orchestration, waits for completion, and returns the output. + /// + public static async Task 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 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()?.Result; + } + /// /// Creates an error response with the specified status code and error message. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md index 93c90bba9c..2c188757d5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md @@ -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 diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionMetadataFactory.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionMetadataFactory.cs index d88cd939d9..46053507b1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionMetadataFactory.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionMetadataFactory.cs @@ -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 DefaultFunctionMetadata CreateOrchestrationTrigger(string functi ScriptFile = BuiltInFunctions.ScriptFile, }; } + + /// + /// Creates function metadata for an MCP tool trigger function that starts a workflow. + /// + /// The name of the workflow to expose as an MCP tool. + /// An optional description for the MCP tool. If null, a default description is generated. + /// A configured for an MCP tool trigger. + 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, + }; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs index ceb47c389a..959ffab2f6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs @@ -67,6 +67,13 @@ public static FunctionsApplicationBuilder ConfigureDurableOptions( builder.Services.ConfigureDurableOptions(configure); + if (sharedOptions.Agents.GetAgentFactories().Count > 0) + { + builder.Services.TryAddSingleton(_ => + new DefaultFunctionsAgentOptionsProvider(DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot())); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + } + if (sharedOptions.Workflows.Workflows.Count > 0) { builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); @@ -102,12 +109,14 @@ private static void EnsureMiddlewareRegistered(FunctionsApplicationBuilder build builder.UseWhen(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(); } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsDurableOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsDurableOptions.cs index 6e7b6ec5a8..ee9051fa0d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsDurableOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsDurableOptions.cs @@ -10,6 +10,7 @@ namespace Microsoft.Agents.AI.Hosting.AzureFunctions; internal sealed class FunctionsDurableOptions : DurableOptions { private readonly HashSet _statusEndpointWorkflows = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _mcpToolTriggerWorkflows = new(StringComparer.OrdinalIgnoreCase); /// /// Enables the status HTTP endpoint for the specified workflow. @@ -26,4 +27,20 @@ internal bool IsStatusEndpointEnabled(string workflowName) { return this._statusEndpointWorkflows.Contains(workflowName); } + + /// + /// Enables the MCP tool trigger for the specified workflow. + /// + internal void EnableMcpToolTrigger(string workflowName) + { + this._mcpToolTriggerWorkflows.Add(workflowName); + } + + /// + /// Returns whether the MCP tool trigger is enabled for the specified workflow. + /// + internal bool IsMcpToolTriggerEnabled(string workflowName) + { + return this._mcpToolTriggerWorkflows.Contains(workflowName); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowOptionsExtensions.cs index 6f40cbb791..7cf38397ae 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowOptionsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowOptionsExtensions.cs @@ -27,4 +27,31 @@ public static void AddWorkflow(this DurableWorkflowOptions options, Workflow wor functionsOptions.EnableStatusEndpoint(workflow.Name!); } } + + /// + /// Adds a workflow and configures whether to expose a status HTTP endpoint and/or an MCP tool trigger. + /// + /// The workflow options to add the workflow to. + /// The workflow instance to add. + /// If , a GET endpoint is generated at workflows/{name}/status/{runId}. + /// If , an MCP tool trigger is generated for the workflow. + 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!); + } + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs index c7ad9a5ebd..8066eefccc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs @@ -113,6 +113,17 @@ public void Transform(IList original) } } + // 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 entry in workflow.Value.ReflectExecutors()) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs index efb02b1aff..af62436ea4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs @@ -5,6 +5,8 @@ using System.Text; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; namespace Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests; /// @@ -235,6 +237,114 @@ await this.WaitForConditionAsync( }); } + [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 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 { { "input", "hello world" } }); + + Assert.NotEmpty(translateResult.Content); + string translateResponse = Assert.IsType(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 { { "input", "ORD-2025-42" } }); + + Assert.NotEmpty(orderResult.Content); + string orderResponse = Assert.IsType(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 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 { { "input", "hello world" } }); + + Assert.NotEmpty(translateResult.Content); + string translateResponse = Assert.IsType(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 { { "query", "What is 2 + 2?" } }); + + Assert.NotEmpty(assistantResult.Content); + string assistantResponse = Assert.IsType(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() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/FunctionMetadataFactoryTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/FunctionMetadataFactoryTests.cs new file mode 100644 index 0000000000..a777c69480 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/FunctionMetadataFactoryTests.cs @@ -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]); + } +}