MCP - From Development to Azure Governance Series: Part 2 - Creating MCP Server with Azure Functions

• MCP, .NET, Azure, Azure Functions, Serverless, Development • 13 min read

In Part 1, you created a .NET-based MCP server called LunchTimeMCP that exposed tools over STDIO. In this part, you’ll take the same restaurant logic and host it in Azure Functions so that MCP clients can call it over HTTP as cloud-hosted tools.

This article focuses on:

  • Adapting the RestaurantService domain logic from Part 1 to use Azure Table Storage for persistent state.
  • Creating a .NET 10 isolated Azure Functions app.
  • Using the Model Context Protocol (MCP) bindings for Azure Functions to expose tools.
  • Running everything locally so an MCP client can call the Functions app.

By the end, you’ll have an MCP server with persistent storage that scales automatically. Note that authentication and authorization are not covered here — those will be addressed in a later part of the series.


Why Azure Functions for MCP-style tools?

Even though MCP is often demonstrated with local STDIO servers, many real-world use cases benefit from putting tool logic behind HTTP in the cloud. The Azure Functions MCP extension lets you expose Azure Functions as a remote MCP server that language models and agents can call directly:

  • Serverless scaling: Only pay for executions and automatically handle bursts.
  • Proximity to data/services: Keep MCP tools close to APIs, databases, and queues that already live in Azure.
  • MCP-aware surface: MCP clients connect to a single /runtime/webhooks/mcp endpoint and discover the tools you define with the MCP bindings.
  • Same .NET code, new host: You reuse the RestaurantService as-is and only change the hosting + binding model.

In later parts of the series, you’ll look at deployment options and governance. For now, you’ll get the Function app running locally and callable from an MCP-capable client.


Starting Point: LunchTimeMCP Domain Logic

From Part 1, you already have the RestaurantService and related models that manage:

  • A list of restaurants.
  • Adding new restaurants.
  • Picking a random restaurant and tracking visits.

In Part 1, the RestaurantService used in-memory lists, which is fine for a local STDIO server. For Azure Functions, you’ll update it to use Azure Table Storage so restaurant data persists across executions and scaling events.

To share domain logic between the console app from Part 1 and the new Functions app, you’ll introduce a shared class library called LunchTimeMCP.Core and copy RestaurantService.cs into it.

Start by creating a fresh solution folder that will host both the shared library and the Functions app:

1
2
3
mkdir LunchTimeMcpSolution
cd LunchTimeMcpSolution
dotnet new sln -n LunchTimeMcp

Now create the shared class library and register it in the solution:

1
2
dotnet new classlib -n LunchTimeMCP.Core --framework net10.0
dotnet sln add LunchTimeMCP.Core/LunchTimeMCP.Core.csproj

Copy RestaurantService.cs from the Part 1 console project into the Core library (replace <path-to-part1> with the actual path):

1
Copy-Item <path-to-part1>/RestaurantService.cs LunchTimeMCP.Core/

Note: RestaurantTools.cs stays in the Part 1 console project. It contains STDIO-specific MCP tool definitions using the ModelContextProtocol SDK, which don’t apply here. The Functions app will get its own LunchTimeMcpTools.cs that uses the Azure Functions MCP bindings instead.

Next, add the Azure Data Tables SDK to the Core library:

1
2
3
cd LunchTimeMCP.Core
dotnet add package Azure.Data.Tables --version 12.11.0
cd ..

With the shared library in place, you’ll update RestaurantService in LunchTimeMCP.Core to use Azure Table Storage, giving the Functions app access to a persistent implementation.


Create the Azure Functions Project (Isolated .NET 10)

You’ll use the .NET isolated worker model, which aligns well with modern .NET and gives you full control over the hosting and DI pipeline.

From the solution folder (LunchTimeMcpSolution), run:

1
2
func init LunchTimeMcpFunction --worker-runtime dotnet-isolated --target-framework net10.0
dotnet sln add LunchTimeMcpFunction/LunchTimeMcpFunction.csproj

Then add a reference to the shared Core library:

1
2
3
cd LunchTimeMcpFunction
dotnet add reference ../LunchTimeMCP.Core/LunchTimeMCP.Core.csproj
cd ..

Restore and build once to be sure everything compiles:

1
2
dotnet restore
dotnet build

Add Persistent Storage with Azure Table Storage

Since Azure Functions can scale to multiple instances and instances can be recycled at any time, storing restaurant data in memory won’t work reliably in production. Instead, you’ll use Azure Table Storage to persist the restaurant list and visit counts.

The beauty of this approach is that Azure Functions already uses Azure Storage for its own runtime needs (AzureWebJobsStorage), so you can reuse the same storage account without additional configuration.

Note: The Azure.Data.Tables package was already added to LunchTimeMCP.Core in the previous step. No additional package installation is needed here.

Next, create a Table Storage entity model for restaurants. Add a new file RestaurantEntity.cs to LunchTimeMCP.Core (alongside RestaurantService.cs):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
using Azure;
using Azure.Data.Tables;
using System;

namespace LunchTimeMCP;

public class RestaurantEntity : ITableEntity
{
    public string PartitionKey { get; set; } = "restaurants";
    public string RowKey { get; set; } = string.Empty;
    public DateTimeOffset? Timestamp { get; set; }
    public ETag ETag { get; set; }

    public string Name { get; set; } = string.Empty;
    public string Location { get; set; } = string.Empty;
    public string FoodType { get; set; } = string.Empty;
    public int VisitCount { get; set; } = 0;
    public DateTime? LastVisited { get; set; }
}

Now update your RestaurantService to use Table Storage instead of in-memory lists. Here’s an updated implementation:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
using Azure.Data.Tables;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace LunchTimeMCP;

public class RestaurantService
{
    private readonly TableClient tableClient;
    private const string TableName = "restaurants";

    public RestaurantService(TableServiceClient tableServiceClient)
    {
        tableClient = tableServiceClient.GetTableClient(TableName);
        tableClient.CreateIfNotExistsAsync().GetAwaiter().GetResult();
    }

    public async Task<List<Restaurant>> GetRestaurantsAsync()
    {
        var restaurants = new List<Restaurant>();

        await foreach (var entity in tableClient.QueryAsync<RestaurantEntity>())
        {
            restaurants.Add(new Restaurant
            {
                Id = entity.RowKey,
                Name = entity.Name,
                Location = entity.Location,
                FoodType = entity.FoodType,
                VisitCount = entity.VisitCount,
                LastVisited = entity.LastVisited
            });
        }

        return restaurants;
    }

    public async Task<Restaurant> AddRestaurantAsync(string name, string location, string foodType)
    {
        var id = Guid.NewGuid().ToString();
        var entity = new RestaurantEntity
        {
            RowKey = id,
            Name = name,
            Location = location,
            FoodType = foodType,
            VisitCount = 0
        };

        await tableClient.AddEntityAsync(entity);

        return new Restaurant
        {
            Id = id,
            Name = name,
            Location = location,
            FoodType = foodType,
            VisitCount = 0
        };
    }

    public async Task<Restaurant?> PickRandomRestaurantAsync()
    {
        var restaurants = await GetRestaurantsAsync();

        if (!restaurants.Any())
        {
            return null;
        }

        var random = new Random();
        var selected = restaurants[random.Next(restaurants.Count)];

        // Update visit count and last visited timestamp
        var entity = await tableClient.GetEntityAsync<RestaurantEntity>("restaurants", selected.Id);
        entity.Value.VisitCount++;
        entity.Value.LastVisited = DateTime.UtcNow;
        await tableClient.UpdateEntityAsync(entity.Value, entity.Value.ETag);

        selected.VisitCount = entity.Value.VisitCount;
        selected.LastVisited = entity.Value.LastVisited;

        return selected;
    }

    public async Task<bool> DeleteRestaurantAsync(string id)
    {
        try
        {
            await tableClient.DeleteEntityAsync("restaurants", id);
            return true;
        }
        catch (Azure.RequestFailedException ex) when (ex.Status == 404)
        {
            return false;
        }
    }
}

public class Restaurant
{
    public string Id { get; set; } = string.Empty;
    public string Name { get; set; } = string.Empty;
    public string Location { get; set; } = string.Empty;
    public string FoodType { get; set; } = string.Empty;
    public int VisitCount { get; set; }
    public DateTime? LastVisited { get; set; }
}

This implementation:

  • Uses TableClient to interact with Azure Table Storage
  • Stores each restaurant as a RestaurantEntity with a unique RowKey
  • Automatically creates the table if it doesn’t exist
  • Persists visit counts and timestamps across function executions
  • Works consistently even when Azure Functions scales to multiple instances

Wire Up Program.cs with RestaurantService and MCP

In a .NET 10 isolated Functions app, Program.cs controls host configuration, dependency injection, and now also MCP tool metadata.

First, install the MCP extension NuGet package in the Functions project:

1
dotnet add package Microsoft.Azure.Functions.Worker.Extensions.Mcp --version 1.2.0-preview.1

Then replace the default Program.cs with something like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
using Azure.Data.Tables;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using LunchTimeMCP; // Namespace where RestaurantService + models live

var builder = FunctionsApplication.CreateBuilder(args);

builder.ConfigureFunctionsWebApplication();

builder.Services
    .AddApplicationInsightsTelemetryWorkerService()
    .ConfigureFunctionsApplicationInsights();

// Register Azure Table Storage client
// This reuses the same storage account that Azure Functions uses (AzureWebJobsStorage)
builder.Services.AddSingleton(sp =>
{
    var connectionString = Environment.GetEnvironmentVariable("AzureWebJobsStorage")
        ?? throw new InvalidOperationException("AzureWebJobsStorage connection string not found");
    return new TableServiceClient(connectionString);
});

// Register domain services
builder.Services.AddSingleton<RestaurantService>();

builder.Build().Run();

The important parts are:

  • Registering TableServiceClient that connects to the storage account specified in the AzureWebJobsStorage environment variable (the same storage Azure Functions already uses).
  • Registering RestaurantService so your Functions can receive it via constructor injection.

Implement MCP Tool Triggers for Your LunchTime Tools

Instead of regular HTTP triggers, you’ll use the MCP tool trigger so that Azure Functions itself behaves as an MCP server. Each function becomes a tool endpoint that MCP clients can call.

Add a new class (for example, LunchTimeMcpTools.cs) to expose tools that map 1:1 to the ones from Part 1.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
using System.Threading.Tasks;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Extensions.Mcp;
using LunchTimeMCP; // RestaurantService + Restaurant models

namespace LunchTimeMcpFunction;

public class LunchTimeMcpTools
{
    private readonly RestaurantService restaurantService;

    public LunchTimeMcpTools(RestaurantService restaurantService)
    {
        this.restaurantService = restaurantService;
    }

    [Function(nameof(GetRestaurants))]
    public async Task<object> GetRestaurants(
        [McpToolTrigger(
            toolName: "get_restaurants",
            description: "Get a list of all restaurants available for lunch.")]
        ToolInvocationContext context)
    {
        var restaurants = await restaurantService.GetRestaurantsAsync();
        return restaurants;
    }

    [Function(nameof(AddRestaurant))]
    public async Task<Restaurant> AddRestaurant(
        [McpToolTrigger(
            toolName: "add_restaurant",
            description: "Add a new restaurant to the lunch options.")]
        ToolInvocationContext context,
        [McpToolProperty(
            propertyName: "name",
            description: "The name of the restaurant.",
            IsRequired = true)]
        string name,
        [McpToolProperty(
            propertyName: "location",
            description: "The location/address of the restaurant.",
            IsRequired = true)]
        string location,
        [McpToolProperty(
            propertyName: "foodType",
            description: "The type of food served (e.g., Italian, Mexican, Thai, etc.)",
            IsRequired = true)]
        string foodType)
    {
        var restaurant = await restaurantService.AddRestaurantAsync(name, location, foodType);
        return restaurant;
    }

    [Function(nameof(DeleteRestaurant))]
    public async Task<object> DeleteRestaurant(
        [McpToolTrigger(
            toolName: "delete_restaurant",
            description: "Delete a restaurant from the lunch options by its ID.")]
        ToolInvocationContext context,
        [McpToolProperty(
            propertyName: "id",
            description: "The ID of the restaurant to delete.",
            IsRequired = true)]
        string id)
    {
        var deleted = await restaurantService.DeleteRestaurantAsync(id);

        if (!deleted)
        {
            return new { message = $"Restaurant with ID '{id}' was not found." };
        }

        return new { message = $"Restaurant with ID '{id}' has been deleted." };
    }

    [Function(nameof(PickRandomRestaurant))]
    public async Task<object> PickRandomRestaurant(
        [McpToolTrigger(
            toolName: "pick_random_restaurant",
            description: "Pick a random restaurant from the available options for lunch.")]
        ToolInvocationContext context)
    {
        var selected = await restaurantService.PickRandomRestaurantAsync();

        if (selected is null)
        {
            return new
            {
                message = "No restaurants available. Please add some restaurants first!"
            };
        }

        return new
        {
            message = "Randomly selected restaurant for lunch.",
            restaurant = selected
        };
    }
}

A few notes about this implementation:

  • [McpToolTrigger]: Marks each function as an MCP tool endpoint with a toolName and description that MCP clients see.
  • [McpToolProperty]: Describes input properties (name, location, foodType) so the client knows what arguments to collect.
  • Return values: You return CLR objects and anonymous objects; the MCP extension serializes them to JSON for the client.

These functions are now first-class MCP tools hosted by Azure Functions, instead of plain HTTP endpoints you have to wire up manually.


Configure local.settings.json and host.json

For local development, make sure you have a local.settings.json similar to this (do not check secrets into source control):

1
2
3
4
5
6
7
{
    "IsEncrypted": false,
    "Values": {
        "AzureWebJobsStorage": "UseDevelopmentStorage=true",
        "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated"
    }
}

The AzureWebJobsStorage setting does double duty here:

  • Azure Functions runtime uses it for internal state and triggers.
  • Your RestaurantService uses it to persist restaurant data in Table Storage.

For local testing, "UseDevelopmentStorage=true" points to Azurite, the local storage emulator. Make sure you have Azurite running before starting the Functions app:

1
2
3
4
5
# Install Azurite globally if you haven't already
npm install -g azurite

# Start Azurite in a separate terminal
azurite

When you deploy to Azure, the AzureWebJobsStorage app setting will automatically point to a real Azure Storage account, and your restaurant data will persist there.

Next, configure MCP-specific settings in host.json so the Functions runtime knows how to describe your MCP server:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
    "version": "2.0",
    "extensions": {
        "mcp": {
            "instructions": "Use these tools to manage and pick lunch restaurants.",
            "serverName": "LunchTimeMcp",
            "serverVersion": "1.0.0",
            "encryptClientState": true
        }
    }
}

Note: The extensionBundle block is not needed here. Extension bundles are an alternative way to add extensions without NuGet, but since you already referenced Microsoft.Azure.Functions.Worker.Extensions.Mcp as a NuGet package, including it would be redundant and can cause conflicts.

The extensions.mcp section is optional but useful — it gives MCP clients human-readable instructions and a friendly server name.


Run the Azure Functions App Locally

From the Functions project folder, start the runtime:

1
2
cd LunchTimeMcpFunction
func start

You should see your MCP tool functions listed in the output. Behind the scenes, the MCP extension also exposes a single MCP server endpoint at:

  • http://localhost:7071/runtime/webhooks/mcp (Streamable HTTP transport)

You don’t call this endpoint directly with curl—instead, MCP-aware clients use it to discover and invoke your tools.


Connecting an MCP Client to the Azure Functions MCP Server

With the MCP bindings in place, the Functions app itself is the MCP server. MCP clients just need to know how to reach it.

For example, to configure GitHub Copilot in Visual Studio Code, you could add a server entry like this in your mcp.json:

1
2
3
4
5
6
7
8
{
    "servers": {
        "local-lunchtime-mcp": {
            "type": "http",
            "url": "http://localhost:7071/runtime/webhooks/mcp"
        }
    }
}

When Copilot connects to this server, it discovers the tools you defined with [McpToolTrigger] (get_restaurants, add_restaurant, delete_restaurant, pick_random_restaurant) and can invoke them directly—no separate HTTP wiring or per-tool URLs required.


Where This Fits in the Series

At this point you have:

  • A console-based MCP server using STDIO (Part 1).
  • A serverless Azure Functions MCP server with persistent storage using Azure Table Storage (this part). Authentication and authorization are not yet covered.

Both expose similar capabilities:

  • Get all restaurants.
  • Add a new restaurant.
  • Delete a restaurant.
  • Pick a random restaurant and track visits.

The difference is about where and how the tools run:

  • Local STDIO server – Great for development, local CLI tools, and quick experiments. Uses in-memory state that resets when the process stops.
  • Azure Functions MCP server – Cloud-hosted, MCP-aware tools that MCP clients can call from anywhere. Uses Azure Table Storage for persistence across function executions, instance scaling, and cold starts.

The Table Storage integration ensures that:

  • Restaurant data persists even when Azure Functions scales out or scales down to zero.
  • Multiple concurrent function instances see a consistent view of the data.
  • You can track visit history over time without losing state.

In future parts of the series, you’ll look at:

  • Different hosting options for MCP-style tools across Azure.
  • How to factor configuration and secrets when running in the cloud.
  • Governance and monitoring once these tools are deployed broadly.

For now, you have a clean, serverless host for your LunchTime MCP logic running on Azure Functions, ready to be wired into your favorite MCP-capable client. Authentication and authorization are intentionally out of scope here — they’ll be covered in a later part of the series.


Check the PlayGoKids repository for this article demo.

Comments & Discussion

Join the conversation! Share your thoughts and connect with other readers.