Your Azure Functions Are Already MCP Tools - Here’s the Last Step
How to transform your existing Azure Functions into MCP tools
If you’re already running Azure Functions, you’re closer to having a working MCP server than you think. One NuGet package, a few attribute decorations, and your existing business logic is callable by any MCP client: GitHub Copilot, Claude Desktop, Azure AI Foundry agents, or your own custom agent.
No separate MCP server project. No new infrastructure. Just a new trigger type sitting alongside your existing bindings.
This post walks through what the extension does, how to wire it up, and the things the quickstart docs don’t warn you about. The companion demo is at github.com/stuartdotnet/azure-functions-mcp-demo.
A Quick Recap on MCP
The Model Context Protocol is the standard that governs how AI agents interact with external tools and data. An agent decides it needs to call a tool, the protocol defines the structure of that exchange, and your function executes. Think of it as a typed interface between the language model and your infrastructure: discoverable at runtime, structured, and composable.
The Functions MCP extension implements the server side of this protocol. Your function app becomes an MCP server. Each function you annotate becomes a tool. Any MCP client can discover what tools you expose and call them.
What You’re Actually Installing
One package:
dotnet add package Microsoft.Azure.Functions.Worker.Extensions.McpA few version requirements to check first:
Microsoft.Azure.Functions.Worker2.1.0 or laterMicrosoft.Azure.Functions.Worker.Sdk2.0.2 or laterAzure Functions Core Tools 4.0.7030 or later
Isolated worker model only. In-process .NET Functions are not supported.
That last point catches people out. If you’re still on the in-process model, this is another nudge to migrate. The isolated model is the current supported path anyway, so it’s not exactly a new constraint.
The extension also needs Azure Queue Storage when using the SSE transport. More on that shortly.
Exposing Your First Tool
The core attribute is [McpToolTrigger]. Apply it to a function and that function becomes an MCP tool endpoint:
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Extensions.Mcp;
public class ProductTools(ILogger<ProductTools> logger)
{
[Function(nameof(GetProductInfo))]
public string GetProductInfo(
[McpToolTrigger("get_product_info", "Returns product details for a given SKU.")]
ToolInvocationContext context,
[McpToolProperty("sku", "The product SKU to look up.", isRequired: true)]
string sku)
{
logger.LogInformation("Product info requested for SKU: {Sku}", sku);
// Replace this with your real service call
return sku switch
{
"WIDGET-001" => "Blue Widget — £12.99 — In stock",
"GADGET-PRO" => "Professional Gadget — £89.99 — Limited stock",
_ => $"Product '{sku}' not found."
};
}
}The first argument to McpToolTrigger is the tool name (what the agent sees). The second is the description, and this matters more than it looks. The LLM uses this description to decide when to call the tool. Write it as a capability statement, not a technical label.
[McpToolProperty] defines the input parameters the tool expects. The MCP client collects and passes these based on what you advertise here. Mark properties isRequired: true if the tool genuinely cannot operate without them. Since version 1.0.0-preview.7, they default to optional.
Adding Azure Bindings
This is where Functions earns its keep as an MCP host. You can combine [McpToolTrigger] with any other Azure Functions binding: Blob Storage, Cosmos DB, Service Bus, whatever you already have wired up.
A tool that reads from Blob Storage:
private const string BlobPath = "products/{mcptoolargs.sku}.json";
[Function(nameof(GetProductDetails))]
public string GetProductDetails(
[McpToolTrigger("get_product_details", "Returns full product details including pricing and stock level.")]
ToolInvocationContext context,
[McpToolProperty("sku", "The product SKU.", isRequired: true)]
string sku,
[BlobInput(BlobPath)] string? productJson)
{
return productJson ?? $"Product '{sku}' not found.";
}The {mcptoolargs.sku} binding expression in the blob path pulls the property value from the tool invocation. Blob path, Cosmos container name, queue: all of these can use mcptoolargs expressions to bind to tool input properties.
This is the real reason to choose Functions over a standalone MCP server. [McpToolTrigger] is just another trigger type (like [HttpTrigger] or [TimerTrigger]). And because it’s a first-class trigger, every input and output binding works with it out of the box. Your blob containers, Cosmos collections, and Service Bus queues are already wired up. You’re not rewriting data access, you’re writing a new function that uses the same binding infrastructure you already have.
Configuring the MCP Server
Add an extensions.mcp section to host.json to define server metadata:
{
"version": "2.0",
"extensions": {
"mcp": {
"instructions": "Product catalog tools for querying inventory and pricing.",
"serverName": "ProductCatalogServer",
"serverVersion": "1.0.0",
"encryptClientState": true,
"system": {
"webhookAuthorizationLevel": "System"
}
}
}
}The instructions field surfaces to MCP clients as server-level guidance. The serverName appears in client UIs. Keep encryptClientState at true in production. Setting it to false is useful for debugging, not something to commit.
The webhookAuthorizationLevel defaults to "System", which means clients need a system key to connect. More on that in the gotchas section.
Connecting a Client
The extension exposes two endpoints. Use the Streamable HTTP one:
Streamable HTTP (preferred):
/runtime/webhooks/mcpSSE (deprecated):
/runtime/webhooks/mcp/sseSSE is deprecated in newer MCP protocol versions. If you’re seeing community examples using the SSE endpoint, they’re working from older docs.
For local testing, no authentication is required. Configure VS Code with a .vscode/mcp.json file:
{
"inputs": [
{
"type": "promptString",
"id": "functions-mcp-extension-system-key",
"description": "Azure Functions MCP Extension System Key",
"password": true
},
{
"type": "promptString",
"id": "functionapp-host",
"description": "Your function app hostname (e.g. my-app.azurewebsites.net)"
}
],
"servers": {
"local-mcp-demo": {
"type": "http",
"url": "http://localhost:7071/runtime/webhooks/mcp"
},
"remote-mcp-demo": {
"type": "http",
"url": "https://${input:functionapp-host}/runtime/webhooks/mcp",
"headers": {
"x-functions-key": "${input:functions-mcp-extension-system-key}"
}
}
}
}The inputs pattern prompts VS Code for values at connection time, but only when they’re referenced via ${input:...}. The local server entry has no input references, so connecting locally requires no prompt. The remote entry references both inputs, so VS Code will ask for the hostname and system key when you first activate it. Neither value ends up in the file or source control.
Activating the server in VS Code
Start the function app locally (
func startor F5). Note that you may need to run Azurite locally first:azurite --location . --debug azurite-debug.logOpen GitHub Copilot Chat and switch to Agent mode using the dropdown at the top of the chat input
Click the settings icon next to the input: this opens a command palette listing available tools and MCP servers
Find your server by name (it matches the key in
serversin.vscode/mcp.json, e.g.local-mcp-demo) and select it to enable itYour tools are now available to the agent
Try it out
With the server active, ask Copilot something that chains tools together:
“What products do you have in stock?”
Copilot will call list_products to discover the catalogue, then follow up with get_product_info on anything flagged as available. You’ll see each tool call appear in the chat as it happens.
To test the Azure binding composability (the thing that makes Functions a genuinely good MCP host), try something that exercises blob storage:
“Save a note titled ‘azure-functions-mcp’ with the content ‘MCP extension works on Azure Functions’. Then retrieve it and confirm what it says.”
That triggers save_note (a blob write via [BlobOutput]) followed by get_note (a blob read via [BlobInput]), all without you writing a single line of storage client code.
Testing Without a Client
Before wiring up GitHub Copilot or any MCP client, it’s useful to verify the endpoint responds correctly with a plain HTTP request. The VS Code REST Client extension handles this. Create a .http file in your project, make sure you include the Accept header that the MCP transport requires:
### List available tools
POST http://localhost:7071/runtime/webhooks/mcp
Content-Type: application/json
Accept: application/json, text/event-stream
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {}
}
### Call get_product_info
POST http://localhost:7071/runtime/webhooks/mcp
Content-Type: application/json
Accept: application/json, text/event-stream
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "get_product_info",
"arguments": {
"sku": "WIDGET-001"
}
}
}A Send Request link appears above each block in VS Code: click it and the response opens in a split panel. This is the fastest way to confirm your tools are registered and returning the right data before involving an agent.
Deploying to Azure
The quickest path to a deployed, secured MCP server:
azd init --template remote-mcp-functions-dotnet -e remote-mcp-demo
azd upThe azd template provisions a Function app on the Flex Consumption plan with Bicep, including the storage account, App Insights, and secure key configuration. The whole thing runs in a few minutes with minimal costs for a demo workload.
To retrieve the system key after deployment:
az functionapp keys list \
--resource-group <YOUR_RG> \
--name <YOUR_APP_NAME> \
--query systemKeys.mcp_extension \
--output tsvPass this as the x-functions-key header value when configuring a remote client.
Remember to clean up your Azure resources when finished.
Gotchas
Isolated worker model only. If you’re on in-process .NET Functions, this doesn’t work. No workaround. Migrate first.
Cold start on Consumption plan. The Flex Consumption plan has cold start latency. For interactive use with GitHub Copilot or similar clients, this matters: a tool that takes five seconds to respond after a cold start is frustrating. Use the Flex Consumption or Premium plan, and consider setting a minimum instance count if the latency is affecting usability.
SSE transport requires Queue Storage. If you’re using the deprecated SSE endpoint, the extension needs Azure Queue Storage with specific RBAC roles: Storage Queue Data Contributor and Storage Queue Data Message Processor. When using identity-based connections, you have to grant these explicitly. The Streamable HTTP endpoint doesn’t have this requirement. Another reason to use it.
The isRequired default changed. Before version 1.0.0-preview.7 of the extension, all tool properties were required by default. After that version, they default to optional. Any code written during the early preview that didn’t explicitly set isRequired: true on required properties will behave differently on newer versions. Worth checking if you’re upgrading an early preview project.
Entra ID auth is multi-step. The system key approach is simple. Microsoft Entra ID identity-based auth is the enterprise answer, but the setup involves enabling App Service auth, adding the VS Code client ID, configuring redirect URIs, setting an environment variable, and adjusting webhookAuthorizationLevel to Anonymous. It works, but don’t expect a five-minute setup.
HTTP testing requires a specific Accept header. If you test the endpoint directly with the VS Code REST Client or curl, you’ll get a 406 Not Acceptable unless you include Accept: application/json, text/event-stream. The error message tells you exactly what’s missing, but nothing in the docs warns you upfront.
When Not to Use Functions for This
Functions is the right MCP host for stateless, independent tools with per-invocation billing. It’s not the right choice for:
Stateful tools. Standard Functions aren’t designed for state between calls. Durable Functions are worth considering here: entity functions give you actor-style stateful objects that persist between invocations, all within the Functions runtime. For anything needing continuous in-memory state or session context across many calls, Container Apps or App Service are better fits.
Multi-server agent architectures. If you’re building multiple MCP servers that need to communicate with each other, Container Apps are a better foundation. Apps in the same Container Apps environment share an internal virtual network and can reach each other by service name without going over the public internet. They’re also always-on, so there’s no cold start penalty when one server calls another mid-request.
Interactive clients on Consumption plan. The cold start latency is a real UX problem. Use Premium or always-on if this matters for your use case.
Microsoft has published a detailed comparison of Azure MCP hosting options. If you’re not sure whether Functions is the right call, that page will tell you.
The Starting Point
If you have existing Azure Functions doing useful work, the MCP extension is genuinely the lowest-friction path to exposing that work to AI agents. One package, one trigger type, and your existing binding infrastructure does the rest.
The pattern is also additive. Your existing HTTP-triggered functions stay exactly as they are. You write new functions with [McpToolTrigger] that sit in the same app, using the same binding infrastructure. You’re not replacing anything, just adding new entry points for agents.
The full demo is at github.com/stuartdotnet/azure-functions-mcp-demo. It includes a working local setup, an .http file for testing without a client, and the azd configuration for one-command deployment.
If you’re building agent tooling on top of an existing .NET or Azure stack and want to talk through the architecture, get in touch at stuartdobson.net.


