Creating an MCP server in PowerApps typically means exposing a secure, typed HTTP API that Power Apps can call via a Custom Connector. This article shows why Azure Functions (.NET 8 isolated) with Azure AD (Entra ID) is the right foundation and how to ship a production-ready MCP-style server with OpenAPI, validation, and zero secret management.
The Problem
You need a reliable backend for Power Apps that enforces validation and security, offers a clean contract (OpenAPI), scales serverlessly, and avoids Function Keys or shared secrets. You also want workflows and data operations to be testable and observable.
Prerequisites
- .NET 8 SDK
- Azure CLI 2.58+ and Azure Functions Core Tools v4+
- Azure subscription with permissions to create Resource Group, Function App, and Managed Identity
- Power Apps environment with permission to create Custom Connectors
Implementation Details
Project setup
// Terminal commands (run locally)
// 1) Create a .NET 8 isolated Azure Functions app
dotnet new func --worker-runtime dotnet-isolated --name PowerAppsMcpServer
cd PowerAppsMcpServer
// 2) Add packages for validation, OpenAPI, and DI helpers
dotnet add package FluentValidation
dotnet add package FluentValidation.DependencyInjectionExtensions
dotnet add package Microsoft.Azure.Functions.Worker.Extensions.OpenApi
dotnet add package Azure.Identity
dotnet add package Microsoft.Extensions.Azure
dotnet add package Microsoft.Extensions.Logging.ApplicationInsights
Program.cs (minimal hosting, DI, OpenAPI)
using Azure.Core;
using Azure.Identity;
using FluentValidation;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Extensions.OpenApi.Extensions;
using Microsoft.Extensions.Azure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
// Top-level statements for .NET 8 minimal hosting
var host = new HostBuilder()
.ConfigureFunctionsWorkerDefaults(builder =>
{
// Enables OpenAPI endpoints at /api/swagger.json and /api/swagger/ui
builder.AddApplicationInsights();
builder.AddOpenApi();
})
.ConfigureServices(services =>
{
// Register Application Insights logger
services.AddLogging();
// Register validators
services.AddValidatorsFromAssemblyContaining<CreateOrderRequestValidator>();
// Register Azure SDK clients using DefaultAzureCredential (Managed Identity in Azure)
services.AddAzureClients(clientBuilder =>
{
clientBuilder.UseCredential(new DefaultAzureCredential());
// Example: clientBuilder.AddSecretClient(new Uri("https://<your-kv>.vault.azure.net/"));
});
// Register domain services
services.AddSingleton<IOrderService, OrderService>();
})
.Build();
host.Run();
Contracts and validation
namespace PowerAppsMcpServer;
public sealed record CreateOrderRequest(
string CustomerId, // Must be a known customer
string Sku, // Product SKU
int Quantity // >= 1
);
public sealed record CreateOrderResponse(
string OrderId,
string Status,
string Message
);
using FluentValidation;
namespace PowerAppsMcpServer;
public sealed class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderRequestValidator()
{
// Ensure CustomerId is present and well-formed
RuleFor(x => x.CustomerId)
.NotEmpty().WithMessage("CustomerId is required.")
.Length(3, 64);
// Basic SKU constraints
RuleFor(x => x.Sku)
.NotEmpty().WithMessage("Sku is required.")
.Length(2, 64);
// Quantity must be positive
RuleFor(x => x.Quantity)
.GreaterThan(0).WithMessage("Quantity must be at least 1.");
}
}
Domain service (DI, testable logic)
using System;
using System.Threading;
using System.Threading.Tasks;
namespace PowerAppsMcpServer;
public interface IOrderService
{
Task<CreateOrderResponse> CreateAsync(CreateOrderRequest request, CancellationToken ct = default);
}
public sealed class OrderService() : IOrderService // Primary constructor (no fields needed)
{
public Task<CreateOrderResponse> CreateAsync(CreateOrderRequest request, CancellationToken ct = default)
{
// Simulate business logic; replace with real persistence/integration
var orderId = $"ORD-{Guid.NewGuid():N}";
return Task.FromResult(new CreateOrderResponse(orderId, "Created", "Order accepted"));
}
}
HTTP-triggered Function with Azure AD auth and OpenAPI
using System.Net;
using System.Text.Json;
using FluentValidation;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Azure.Functions.Worker.Extensions.OpenApi.Attributes;
using Microsoft.OpenApi.Models;
namespace PowerAppsMcpServer;
public sealed class CreateOrderFunction(
ILogger<CreateOrderFunction> logger,
IValidator<CreateOrderRequest> validator,
IOrderService orderService)
{
// This endpoint is described for OpenAPI and secured via Azure AD (set on the Function App)
[Function("CreateOrder")]
[OpenApiOperation(operationId: "CreateOrder", tags: new[] { "orders" }, Summary = "Create order", Description = "Creates an order with validated input.")]
[OpenApiRequestBody(contentType: "application/json", bodyType: typeof(CreateOrderRequest), Required = true, Description = "Order payload")]
[OpenApiResponseWithBody(statusCode: HttpStatusCode.OK, contentType: "application/json", bodyType: typeof(CreateOrderResponse), Summary = "Order created")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Function, "post", Route = "orders")] HttpRequestData req,
FunctionContext ctx)
{
// Note: Use EasyAuth/Azure AD at the Function App level to avoid handling tokens here.
// The function still requires a valid AAD token via Custom Connector.
var payload = await JsonSerializer.DeserializeAsync<CreateOrderRequest>(req.Body, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
// Validate request
var result = await validator.ValidateAsync(payload!);
if (!result.IsValid)
{
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteStringAsync(JsonSerializer.Serialize(new
{
error = "ValidationFailed",
details = result.Errors.Select(e => new { e.PropertyName, e.ErrorMessage })
}));
return bad;
}
// Execute domain logic
var responseModel = await orderService.CreateAsync(payload!, ctx.CancellationToken);
// Return success
var ok = req.CreateResponse(HttpStatusCode.OK);
await ok.WriteStringAsync(JsonSerializer.Serialize(responseModel));
return ok;
}
}
Note: Set the Function App to be secured by Azure AD (App Service Authentication/Authorization). Do not rely on Function Keys in production.
OpenAPI for the Custom Connector
- Run locally and navigate to /api/swagger/ui to inspect the contract. The OpenAPI JSON is available at /api/swagger.json.
- Export this JSON and import it when creating your Power Apps Custom Connector, selecting OAuth 2.0 (Azure AD) as the authentication type.
Power Apps integration (Custom Connector)
Authentication setup
- Create an Azure AD App Registration for the Custom Connector (client app) and expose an application ID URI for the Function App (resource app) if you choose user-assigned scopes. Alternatively, enable EasyAuth with “Log in with Azure Active Directory”.
- In the Custom Connector, choose OAuth 2.0 (Azure AD). Provide the Authorization URL, Token URL, and the client application details. Use the Application ID URI or scope configured for the Function App.
- Grant users access via Azure AD and Power Platform permissions so they can acquire tokens and use the connector.
Calling the connector from Power Apps
// Power Apps (Canvas) example formula usage (pseudo):
// Assuming Custom Connector named 'OrdersApi'
Set(
createResult,
OrdersApi.CreateOrder({
CustomerId: "CUST-001",
Sku: "WIDGET-42",
Quantity: 2
})
);
// Access response fields: createResult.OrderId, createResult.Status, createResult.Message
Deployment
// Azure deployment with CLI (example)
// 1) Login
az login
// 2) Create resource group
az group create -n rg-mcp-powerapps -l eastus
// 3) Create storage and function app (Linux, isolated, .NET 8)
az storage account create -n mcpfuncstor$RANDOM -g rg-mcp-powerapps -l eastus --sku Standard_LRS
az functionapp create -n mcp-func-app-$RANDOM -g rg-mcp-powerapps -s <storageName> --consumption-plan-location eastus --runtime dotnet-isolated --functions-version 4
// 4) Enable App Service Authentication with Azure AD (EasyAuth)
# Replace <clientId>, <issuerUrl> as per your AAD setup
az webapp auth microsoft update \
--resource-group rg-mcp-powerapps \
--name mcp-func-app-XXXX \
--client-id <clientId> \
--issuer "https://login.microsoftonline.com/<tenantId>/v2.0" \
--unauthenticated-client-action RedirectToLoginPage
// 5) Deploy code
func azure functionapp publish mcp-func-app-XXXX
RBAC roles to assign
- Deployment automation: Contributor on the resource group or scoped roles to Function App and Storage Account.
- Function App management: Azure Functions Contributor (for CI/CD and app updates).
- Storage access (if using Managed Identity to access blobs/queues): Storage Blob Data Reader or Storage Blob Data Contributor as needed, following least privilege.
- Application Insights access (read-only dashboards): Monitoring Reader.
Validation and typing on the client
If you invoke this API from a TypeScript app (e.g., React), validate payloads before sending. Below is a strictly typed example using Zod.
// TypeScript (strict) example with Zod
import { z } from "zod";
const CreateOrderRequest = z.object({
CustomerId: z.string().min(3).max(64),
Sku: z.string().min(2).max(64),
Quantity: z.number().int().positive()
});
type CreateOrderRequest = z.infer<typeof CreateOrderRequest>;
async function createOrder(apiBase: string, token: string, body: CreateOrderRequest) {
const payload = CreateOrderRequest.parse(body); // Validates at runtime
const res = await fetch(`${apiBase}/api/orders`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json() as { OrderId: string; Status: string; Message: string };
}
Best Practices & Security
- Authentication: Use Azure AD (EasyAuth) with the Custom Connector. Avoid Function Keys to eliminate shared secrets.
- Authorization: Scope access by Entra app roles or scopes; assign least-privilege RBAC to managed identities.
- Secrets: Prefer Managed Identity and DefaultAzureCredential to access downstream services. Avoid storing credentials in app settings.
- Validation: Enforce input validation on the server (FluentValidation) and on the client (Zod) for robust defense-in-depth.
- Observability: Enable Application Insights. Correlate requests by logging operation IDs and include key business fields.
- Resiliency: Add retry policies on the client, idempotency keys for order creation, and request timeouts to prevent retries creating duplicates.
- Versioning: Version endpoints (e.g., /api/v1/orders) and keep your Custom Connector mapped to a specific version.
- Cost: Consumption plan scales to zero; set sampling in Application Insights to control telemetry cost.
Best Practice: Use AsNoTracking() in Entity Framework when performing read-only queries to improve performance.
Monitoring and alerting
With Application Insights, create alerts on failed requests or latency thresholds.
// Examples (Kusto)
// 1) High failure rate in the last 15 minutes
requests
| where timestamp > ago(15m)
| summarize FailureRate = 100.0 * countif(success == false) / count()
// 2) P95 latency by operation
requests
| where timestamp > ago(1h)
| summarize p95_duration = percentile(duration, 95) by operation_Name
Summary
- Build your MCP-style server for Power Apps with .NET 8 isolated Azure Functions, DI, and FluentValidation.
- Secure the API with Azure AD and avoid Function Keys; integrate via a Custom Connector using OAuth 2.0.
- Ship with OpenAPI, monitoring, and clear RBAC to ensure production readiness.
No comments:
Post a Comment