In the previous post, we built a small MCP server in .NET, exposed it over Streamable HTTP, containerized it, deployed it to Azure Container Apps, and called it from a C# client.

This follow-up keeps the same server and changes the host. The tools stay the same. The client stays almost the same. Instead of running the server as a container app, we run the existing ASP.NET Core MCP server on Azure Functions as a self-hosted custom handler.

Previous post: Build and deploy an MCP server with .NET and Azure Container Apps

Repository: MCP Server on Azure Container Apps with C# and .NET

The sample exposes three tools:

  • echo: returns a message
  • add: adds two integers
  • server_time: returns the server time for a supplied time zone

The point of this post is not to rewrite those tools as Azure Functions. The point is to keep the MCP server as an SDK-based ASP.NET Core app and let Azure Functions host it.

Prerequisites

You need:

  • .NET 10 SDK
  • Azure Functions Core Tools v4
  • Azure CLI 2.60.0 or later
  • An Azure subscription
  • Optional: Node.js if you want to test with the MCP Inspector

The Azure Functions self-hosted MCP server support is in public preview at the time of writing. The important constraints are:

  • The MCP server must be stateless.
  • The MCP server must use Streamable HTTP.
  • The Function App must use Flex Consumption.
  • Local execution must use func start.

That fits the sample from the Container Apps post because it already uses stateless Streamable HTTP:

builder.Services
    .AddMcpServer(options =>
    {
        options.ServerInfo = new()
        {
            Name = "mcp-azure-container-apps-demo",
            Version = "1.0.0"
        };
    })
    .WithHttpTransport(options => options.Stateless = true)
    .WithToolsFromAssembly();

app.MapMcp("/mcp");

If your server depends on process-local sessions, in-memory subscriptions, or long-lived state, stop here and redesign that part first. Azure Functions can scale out and recycle instances. State that matters should live outside the process.

Start from the existing repository

Clone the repository from the previous post:

git clone https://github.com/ovnecron/mcp-azure-container-apps-demo.git
cd mcp-azure-container-apps-demo

I would do the Functions version on a separate branch:

git checkout -b azure-functions

The starting point looks like this:

McpAzureContainerAppsDemo/
  McpAzureContainerAppsDemo.slnx
  Directory.Build.props
  Directory.Packages.props
  src/
    McpAzureContainerAppsDemo.Server/
      Program.cs
      McpAzureContainerAppsDemo.Server.csproj
      Configuration/
      Security/
      Services/
      Tools/
    McpAzureContainerAppsDemo.Client/
      Program.cs
      Configuration/
      Rendering/
  tests/
    McpAzureContainerAppsDemo.Server.Tests/

The Container Apps version also has a Dockerfile. You can leave it in the repository if you want one branch to support both hosts, but Azure Functions will not use it in this walkthrough.

Why custom handlers

Azure Functions has two MCP paths:

  1. Build tools with the Azure Functions MCP extension.
  2. Host an existing SDK-based MCP server as a self-hosted custom handler.

The first path is useful when you want the Functions programming model from the start. You write Function methods and expose tools with the Functions MCP extension.

That is not what we need here.

The existing sample is already an ASP.NET Core MCP server using the official .NET MCP SDK. Rewriting the tools as Function methods would create a second implementation for no good reason.

With the self-hosted approach, the request path is:

MCP client
  -> Azure Functions host
  -> custom handler process
  -> ASP.NET Core MCP server
  -> /mcp

The Functions host starts the web server process and proxies HTTP requests to it. From the MCP client’s point of view, the endpoint is still a remote Streamable HTTP MCP server.

Change Program.cs

Open src/McpAzureContainerAppsDemo.Server/Program.cs.

Add an explicit URL binding immediately after the builder is created:

var builder = WebApplication.CreateBuilder(args);

var customHandlerPort = Environment.GetEnvironmentVariable("FUNCTIONS_CUSTOMHANDLER_PORT") ?? "8080";
builder.WebHost.UseUrls($"http://0.0.0.0:{customHandlerPort}");

The port matters. The Functions host needs to know where the custom handler process is listening.

FUNCTIONS_CUSTOMHANDLER_PORT is the idiomatic way for an Azure Functions custom handler process to discover the port it should bind to. The fallback to 8080 keeps direct local runs predictable when that environment variable is not set.

This does not change the MCP tool registration:

builder.Services
    .AddMcpServer(options =>
    {
        options.ServerInfo = new()
        {
            Name = "mcp-azure-container-apps-demo",
            Version = "1.0.0"
        };
    })
    .WithHttpTransport(options => options.Stateless = true)
    .WithToolsFromAssembly();

And the endpoint stays the same:

app.MapMcp("/mcp");

That is the useful part of this migration. We are changing hosting, not tool implementation.

Add host.json

Create src/McpAzureContainerAppsDemo.Server/host.json:

{
  "version": "2.0",
  "configurationProfile": "mcp-custom-handler",
  "customHandler": {
    "description": {
      "defaultExecutablePath": "dotnet",
      "arguments": ["McpAzureContainerAppsDemo.Server.dll"]
    },
    "http": {
      "defaultAuthorizationLevel": "anonymous"
    },
    "port": "8080"
  }
}

This is the bridge between Azure Functions and the existing ASP.NET Core server.

The custom handler command is:

dotnet McpAzureContainerAppsDemo.Server.dll

The port value must match the URL binding in Program.cs.

In this sample, host.json declares 8080. The ASP.NET Core app reads FUNCTIONS_CUSTOMHANDLER_PORT when the Functions host provides it and falls back to the same 8080 value for local or direct runs.

The configurationProfile value of mcp-custom-handler is important. It configures the Functions host for the MCP custom-handler shape, including HTTP proxying and a catch-all route so requests such as /mcp pass through to the ASP.NET Core server.

The defaultAuthorizationLevel is anonymous here because this sample keeps the API-key middleware from the Container Apps post. That middleware protects /mcp when McpServer__ApiKey is configured and leaves /health open for smoke tests.

For a real system, I would not stop at a static API key. Use built-in authentication with Microsoft Entra ID, managed identity for downstream Azure services, and authorization rules that match what your tools can access.

Add local Functions settings

Create src/McpAzureContainerAppsDemo.Server/local.settings.json for local development:

{
  "IsEncrypted": false,
  "Values": {
    "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
    "AzureWebJobsFeatureFlags": "EnableMcpCustomHandlerPreview",
    "McpServer__ApiKey": "local-dev-key"
  }
}

Do not commit real secrets in local.settings.json. For a public repository, commit a local.settings.json.example if you want to document the required keys, and keep the real file local.

The McpServer__ApiKey key is the same setting used in the Container Apps version. ASP.NET Core configuration maps it to McpServer:ApiKey, and the existing middleware checks the incoming X-Api-Key header.

Copy host.json on publish

Update src/McpAzureContainerAppsDemo.Server/McpAzureContainerAppsDemo.Server.csproj so host.json is copied into the build and publish output:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="ModelContextProtocol.AspNetCore" />
  </ItemGroup>

  <ItemGroup>
    <None Update="host.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
      <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
    </None>
    <None Update="local.settings.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
      <CopyToPublishDirectory>Never</CopyToPublishDirectory>
    </None>
  </ItemGroup>

</Project>

host.json must be at the root of the Function App package. Azure Functions reads it to know how to start and proxy to the custom handler.

local.settings.json is only for local development and should not be published.

Run locally with Azure Functions Core Tools

Start the server through the Functions host:

cd src/McpAzureContainerAppsDemo.Server
func start

The Functions host listens on 7071 by default.

The ASP.NET Core custom handler listens on the custom handler port internally. With the sample settings, that is 8080. Clients connect to the Functions host:

http://localhost:7071/mcp

In another terminal, run the existing C# client from the repository root:

dotnet run --project src/McpAzureContainerAppsDemo.Client/McpAzureContainerAppsDemo.Client.csproj -- \
  --url http://localhost:7071/mcp \
  --api-key local-dev-key \
  --time-zone Europe/Berlin

You should see the same behavior as the Container Apps version:

  • the client lists the available tools
  • add returns a sum
  • server_time returns the server time for the requested time zone

You can also smoke test the normal HTTP endpoint:

curl http://localhost:7071/health

If this does not respond, check the Functions terminal first. Most local failures are a wrong DLL name in host.json, a port mismatch, or missing Azure Functions Core Tools support.

Test with MCP Inspector

You can inspect the tool schema with MCP Inspector:

npx @modelcontextprotocol/inspector

Use Streamable HTTP and connect to:

http://localhost:7071/mcp

Add the API key header:

X-Api-Key: local-dev-key

This verifies the MCP surface without relying on the C# client.

Publish the Function App package

Build a publish output from the repository root:

dotnet publish src/McpAzureContainerAppsDemo.Server/McpAzureContainerAppsDemo.Server.csproj \
  --configuration Release \
  --output .publish/mcp-functions

Check that the publish output contains both the DLL and host.json:

ls .publish/mcp-functions/McpAzureContainerAppsDemo.Server.dll
ls .publish/mcp-functions/host.json

Create a zip package from the contents of the publish directory.

On macOS or Linux:

cd .publish/mcp-functions
zip -r ../mcp-functions.zip .
cd ../..

On Windows PowerShell:

Compress-Archive `
  -Path .\.publish\mcp-functions\* `
  -DestinationPath .\.publish\mcp-functions.zip `
  -Force

The package must contain host.json at the zip root. Do not zip the parent folder itself. This is one of the most common causes of custom handler deployments that build successfully but fail at startup.

Create Azure resources

Login and choose a Flex Consumption region:

az login

az functionapp list-flexconsumption-locations \
  --query "sort_by(@, &name)[].{Region:name}" \
  --output table

Set variables:

export RESOURCE_GROUP=mcp-functions-demo-rg
export LOCATION=westeurope
export UNIQUE_SUFFIX=$RANDOM$RANDOM
export STORAGE_ACCOUNT=mcpfunc$UNIQUE_SUFFIX
export FUNCTION_APP_NAME=mcp-func-demo-$UNIQUE_SUFFIX
export MCP_API_KEY="$(openssl rand -base64 32)"

Register the provider used by the current self-hosted MCP Functions flow:

az provider register --namespace Microsoft.App

Create the resource group:

az group create \
  --name "$RESOURCE_GROUP" \
  --location "$LOCATION"

Create the storage account:

az storage account create \
  --name "$STORAGE_ACCOUNT" \
  --resource-group "$RESOURCE_GROUP" \
  --location "$LOCATION" \
  --sku Standard_LRS \
  --allow-blob-public-access false

Create the Function App on Flex Consumption:

az functionapp create \
  --resource-group "$RESOURCE_GROUP" \
  --name "$FUNCTION_APP_NAME" \
  --storage-account "$STORAGE_ACCOUNT" \
  --flexconsumption-location "$LOCATION" \
  --runtime dotnet-isolated \
  --runtime-version 10.0

The existing sample targets net10.0, so the Function App runtime version should match. If your Azure CLI does not accept 10.0, update the Azure CLI and check whether .NET 10 is available in the selected region.

Add the MCP preview feature flag and the sample API key:

Remember that local.settings.json does not deploy to Azure, so you must recreate those environment variables as Function App settings in the cloud.

az functionapp config appsettings set \
  --name "$FUNCTION_APP_NAME" \
  --resource-group "$RESOURCE_GROUP" \
  --settings \
    AzureWebJobsFeatureFlags=EnableMcpCustomHandlerPreview \
    McpServer__ApiKey="$MCP_API_KEY"

FUNCTIONS_WORKER_RUNTIME is set by the az functionapp create command because the app was created with --runtime dotnet-isolated.

Deploy the package

Deploy the zip package:

az functionapp deployment source config-zip \
  --resource-group "$RESOURCE_GROUP" \
  --name "$FUNCTION_APP_NAME" \
  --src .publish/mcp-functions.zip

After deployment, the MCP endpoint is:

https://<function-app-name>.azurewebsites.net/mcp

For this walkthrough, that becomes:

echo "https://$FUNCTION_APP_NAME.azurewebsites.net/mcp"

Smoke test the health endpoint:

curl "https://$FUNCTION_APP_NAME.azurewebsites.net/health"

Then call the deployed MCP server with the existing C# client:

dotnet run --project src/McpAzureContainerAppsDemo.Client/McpAzureContainerAppsDemo.Client.csproj -- \
  --url "https://$FUNCTION_APP_NAME.azurewebsites.net/mcp" \
  --api-key "$MCP_API_KEY" \
  --time-zone Europe/Berlin

If everything is wired correctly, the output should look like the local run. The hosting platform changed, but the MCP surface did not.

What changed compared to Container Apps

The Container Apps version needed a Dockerfile, container build, ingress target port, and Container Apps deployment command.

The Functions version needs:

  • host.json
  • local.settings.json for local development
  • an explicit ASP.NET Core port binding
  • project file entries to publish host.json
  • a Flex Consumption Function App
  • a zip deployment package

The MCP tools did not change.

The services did not change.

The C# client only changed the URL:

Container Apps:
https://<container-app-fqdn>/mcp

Azure Functions:
https://<function-app-name>.azurewebsites.net/mcp

That is the design goal. If your MCP server keeps protocol wiring thin and tool behavior in normal services, the hosting layer can change without rewriting the application.

Azure Container Apps vs Azure Functions

Neither host is automatically better.

Use Azure Container Apps when:

  • you want the MCP server to behave like a normal containerized web app
  • you want direct control over the image and container environment
  • you already use ACA environments, revisions, and ingress
  • the app is likely to grow beyond a small request-driven MCP surface

Use Azure Functions when:

  • the MCP server is stateless and uses Streamable HTTP
  • you want a serverless hosting model
  • your team already uses Function Apps and Functions deployment workflows
  • you want to use Functions platform features around authentication, managed identity, and operations

For this sample, both work. The useful lesson is that the same MCP server can move between hosts with a small hosting-layer change.

Troubleshooting checklist

If func start works but /mcp does not respond, check:

  1. Does host.json point to McpAzureContainerAppsDemo.Server.dll?
  2. Does host.json use port 8080?
  3. Does Program.cs read FUNCTIONS_CUSTOMHANDLER_PORT and fall back to 8080?
  4. Are you calling the Functions host URL, http://localhost:7071/mcp, not http://localhost:8080/mcp?
  5. Is AzureWebJobsFeatureFlags=EnableMcpCustomHandlerPreview set locally?
  6. If API-key protection is enabled, did you send X-Api-Key or pass --api-key to the client?
  7. Is the MCP Inspector using Streamable HTTP?

If Azure deployment succeeds but the remote endpoint fails, check:

  1. Does the zip contain host.json at the root?
  2. Does the zip contain McpAzureContainerAppsDemo.Server.dll?
  3. Is the Function App running on Flex Consumption?
  4. Is the Function App runtime set to dotnet-isolated with a runtime version that can run the target framework?
  5. Are AzureWebJobsFeatureFlags and McpServer__ApiKey set as app settings?
  6. Do the Function App logs show a custom handler startup failure?

Most failures in this shape are not MCP tool bugs. They are usually host configuration, port mismatch, publish package shape, or missing auth headers.

Cleanup

Delete the resource group when you are done:

az group delete \
  --name "$RESOURCE_GROUP" \
  --yes \
  --no-wait

When to use this

Use this approach when you already have a stateless SDK-based MCP server in .NET and you want to host it remotely on Azure Functions without rewriting your tools into Function trigger methods.

It is a good fit for focused, request-driven tool servers where the MCP layer stays thin and the tool behavior lives in normal C# services.

When not to use this

Do not use this sample as-is when tools can read production data, mutate resources, or require user-level authorization. Replace the static API key with a stronger authentication and authorization model, use managed identity for downstream Azure access, add observability around tool calls, and test the deployed /mcp endpoint.

Further reading