Edit

Share via


Tutorial: Deploy a .NET MCP server to Azure Container Apps

In this tutorial, you build a Model Context Protocol (MCP) server that exposes task-management tools by using ASP.NET Core and the ModelContextProtocol.AspNetCore NuGet package. You deploy the server to Azure Container Apps and connect to it from GitHub Copilot Chat in VS Code.

In this tutorial, you:

  • Create an ASP.NET Core app that exposes MCP tools
  • Test the MCP server locally with GitHub Copilot
  • Containerize and deploy the app to Azure Container Apps
  • Connect GitHub Copilot to the deployed MCP server

Prerequisites

Create the app scaffold

In this section, you create a new ASP.NET Core project and configure it as an MCP server.

  1. Create a new ASP.NET Core Web API project:

    dotnet new web -n TasksMcpServer
    cd TasksMcpServer
    
  2. Add the MCP server NuGet package:

    dotnet add package ModelContextProtocol.AspNetCore --prerelease
    
  3. Replace the contents of Program.cs with the following code:

    var builder = WebApplication.CreateBuilder(args);
    
    builder.Services.AddMcpServer()
        .WithHttpTransport()
        .WithToolsFromAssembly();
    
    builder.Services.AddCors(options =>
    {
        options.AddDefaultPolicy(policy =>
        {
            policy.AllowAnyOrigin()
                  .AllowAnyHeader()
                  .AllowAnyMethod();
        });
    });
    
    builder.Services.AddSingleton<TaskStore>();
    
    var app = builder.Build();
    
    app.UseCors();
    
    app.MapGet("/health", () => Results.Ok("healthy"));
    app.MapMcp("/mcp");
    
    app.Run();
    

    Key points:

    • AddMcpServer().WithHttpTransport().WithToolsFromAssembly() registers the MCP server and discovers all classes marked with [McpServerToolType].
    • MapMcp("/mcp") mounts the streamable HTTP endpoint at /mcp.
    • AddSingleton<TaskStore>() registers the in-memory data store you create in the next section.
    • You add a /health endpoint separately for Azure Container Apps health probes. MCP endpoints return JSON-RPC responses that aren't suitable as health checks.
    • You enable CORS because GitHub Copilot in VS Code makes cross-origin requests to MCP servers.

Define the MCP tools

Next, define the task-management data store and the MCP tools that expose it to AI clients.

  1. Create a file named TaskStore.cs for the in-memory data store:

    namespace TasksMcpServer;
    
    public record TaskItem(int Id, string Title, string Description, bool IsComplete, DateTime CreatedAt);
    
    public class TaskStore
    {
        private readonly List<TaskItem> _tasks = new()
        {
            new(1, "Buy groceries", "Milk, eggs, bread", false, DateTime.UtcNow),
            new(2, "Write docs", "Draft the MCP tutorial", true, DateTime.UtcNow.AddDays(-1)),
        };
    
        private int _nextId = 3;
    
        public List<TaskItem> GetAll() => _tasks.ToList();
    
        public TaskItem? GetById(int id) => _tasks.FirstOrDefault(t => t.Id == id);
    
        public TaskItem Create(string title, string description)
        {
            var task = new TaskItem(_nextId++, title, description, false, DateTime.UtcNow);
            _tasks.Add(task);
            return task;
        }
    
        public TaskItem? ToggleComplete(int id)
        {
            var index = _tasks.FindIndex(t => t.Id == id);
            if (index < 0) return null;
            var old = _tasks[index];
            var updated = old with { IsComplete = !old.IsComplete };
            _tasks[index] = updated;
            return updated;
        }
    
        public bool Delete(int id)
        {
            var task = _tasks.FirstOrDefault(t => t.Id == id);
            if (task is null) return false;
            _tasks.Remove(task);
            return true;
        }
    }
    

    The TaskItem record defines the data model with five properties. The TaskStore class manages an in-memory list prepopulated with sample data and provides methods to list, find, create, toggle, and delete tasks.

  2. Create a file named TasksMcpTools.cs with the MCP tool definitions:

    using System.ComponentModel;
    using ModelContextProtocol.Server;
    
    namespace TasksMcpServer;
    
    [McpServerToolType]
    public class TasksMcpTools
    {
        private readonly TaskStore _store;
    
        public TasksMcpTools(TaskStore store)
        {
            _store = store;
        }
    
        [McpServerTool, Description("Lists all tasks with their ID, title, description, and completion status.")]
        public List<TaskItem> ListTasks()
        {
            return _store.GetAll();
        }
    
        [McpServerTool, Description("Gets a single task by its ID.")]
        public TaskItem? GetTask(
            [Description("The numeric ID of the task to retrieve")] int id)
        {
            return _store.GetById(id);
        }
    
        [McpServerTool, Description("Creates a new task with the given title and description. Returns the created task.")]
        public TaskItem CreateTask(
            [Description("A short title for the task")] string title,
            [Description("A detailed description of what the task involves")] string description)
        {
            return _store.Create(title, description);
        }
    
        [McpServerTool, Description("Toggles a task's completion status between complete and incomplete.")]
        public string ToggleTaskComplete(
            [Description("The numeric ID of the task to toggle")] int id)
        {
            var task = _store.ToggleComplete(id);
            return task is not null
                ? $"Task {task.Id} is now {(task.IsComplete ? "complete" : "incomplete")}."
                : $"Task with ID {id} not found.";
        }
    
        [McpServerTool, Description("Deletes a task by its ID.")]
        public string DeleteTask(
            [Description("The numeric ID of the task to delete")] int id)
        {
            return _store.Delete(id)
                ? $"Task {id} deleted."
                : $"Task with ID {id} not found.";
        }
    }
    

    The [McpServerToolType] attribute marks the class as an MCP tool provider. Each [McpServerTool] method becomes an invocable tool. Use [Description] attributes to help the AI model understand each tool's purpose and parameters.

Test the MCP server locally

Before deploying to Azure, verify the MCP server works by running it locally and connecting from GitHub Copilot.

  1. Run the application:

    dotnet run
    

    The server starts on http://localhost:5000 (or the port shown in the console output). The MCP endpoint is at http://localhost:5000/mcp.

  2. Open VS Code, then open Copilot Chat and select Agent mode.

  3. Select the Tools button, and then select Add More Tools... > Add MCP Server.

  4. Select HTTP (HTTP or Server-Sent Events).

  5. Enter the server URL: http://localhost:5000/mcp

  6. Enter a server ID: tasks-mcp

  7. Select Workspace Settings.

  8. In a new Copilot Chat prompt, type: "Show me all tasks"

  9. GitHub Copilot shows a confirmation before invoking the MCP tool. Select Continue.

You should see Copilot return the list of tasks from your in-memory store.

Tip

Try other prompts like "Create a task to review the PR", "Mark task 1 as complete", or "Delete task 2".

Containerize the application

Package the application as a Docker container so you can test it locally before deploying to Azure.

  1. Create a Dockerfile in the project root:

    FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
    WORKDIR /src
    COPY *.csproj .
    RUN dotnet restore
    COPY . .
    RUN dotnet publish -c Release -o /app
    
    FROM mcr.microsoft.com/dotnet/aspnet:8.0
    WORKDIR /app
    COPY --from=build /app .
    ENV ASPNETCORE_URLS=http://+:8080
    EXPOSE 8080
    ENTRYPOINT ["dotnet", "TasksMcpServer.dll"]
    

    The multi-stage build uses the SDK image to restore, build, and publish the app, then copies only the published output to a smaller ASP.NET runtime image. The ASPNETCORE_URLS environment variable configures the app to listen on port 8080.

  2. Verify the container builds and runs locally:

    docker build -t tasks-mcp-server .
    docker run -p 8080:8080 tasks-mcp-server
    

    Confirm the health endpoint responds: curl http://localhost:8080/health

Deploy to Azure Container Apps

After you containerize the application, deploy it to Azure Container Apps by using Azure CLI. The az containerapp up command builds the container image in the cloud, so you don't need Docker on your machine for this step.

  1. Set environment variables:

    RESOURCE_GROUP="mcp-tutorial-rg"
    LOCATION="eastus"
    ENVIRONMENT_NAME="mcp-env"
    APP_NAME="tasks-mcp-server"
    
  2. Create a resource group:

    az group create --name $RESOURCE_GROUP --location $LOCATION
    
  3. Create a Container Apps environment:

    az containerapp env create \
        --name $ENVIRONMENT_NAME \
        --resource-group $RESOURCE_GROUP \
        --location $LOCATION
    
  4. Deploy the container app:

    az containerapp up \
        --name $APP_NAME \
        --resource-group $RESOURCE_GROUP \
        --environment $ENVIRONMENT_NAME \
        --source . \
        --ingress external \
        --target-port 8080
    
  5. Configure CORS to allow GitHub Copilot requests:

    az containerapp ingress cors enable \
        --name $APP_NAME \
        --resource-group $RESOURCE_GROUP \
        --allowed-origins "*" \
        --allowed-methods "GET,POST,DELETE,OPTIONS" \
        --allowed-headers "*"
    

    Note

    For production, replace the wildcard * origins with specific trusted origins. See Secure MCP servers on Container Apps for guidance.

  6. Verify the deployment:

    APP_URL=$(az containerapp show \
        --name $APP_NAME \
        --resource-group $RESOURCE_GROUP \
        --query "properties.configuration.ingress.fqdn" -o tsv)
    
    curl https://$APP_URL/health
    

Connect GitHub Copilot to the deployed server

Now that the MCP server is running in Azure, configure VS Code to connect GitHub Copilot to the deployed endpoint.

  1. In your project, create or update .vscode/mcp.json:

    {
        "servers": {
            "tasks-mcp-server": {
                "type": "http",
                "url": "https://<your-app-fqdn>/mcp"
            }
        }
    }
    

    Replace <your-app-fqdn> with the FQDN from the deployment output.

  2. In VS Code, open Copilot Chat in Agent mode.

  3. If the server doesn't appear automatically, select the Tools button and verify tasks-mcp-server is listed. Select Start if needed.

  4. Test with a prompt like "List all my tasks" to confirm the deployed MCP server responds.

Configure scaling for interactive use

By default, Azure Container Apps can scale to zero replicas. For MCP servers that serve interactive clients like Copilot, cold starts cause noticeable delays. Set a minimum replica count to keep at least one instance running:

az containerapp update \
    --name $APP_NAME \
    --resource-group $RESOURCE_GROUP \
    --min-replicas 1

Security considerations

This tutorial uses an unauthenticated MCP server for simplicity. Before running an MCP server in production, review the following recommendations. When an agent powered by large language models (LLMs) calls your MCP server, be aware of prompt injection attacks.

  • Authentication and authorization: Secure your MCP server by using Microsoft Entra ID. See Secure MCP servers on Container Apps.
  • Input validation: Always validate tool parameters. Use data annotations or FluentValidation in ASP.NET Core. See Model validation in ASP.NET Core.
  • HTTPS: Azure Container Apps enforces HTTPS by default with automatic TLS certificates.
  • Least privilege: Expose only the tools your use case requires. Avoid tools that perform destructive operations without confirmation.
  • CORS: Restrict allowed origins to trusted domains in production.
  • Logging and monitoring: Log MCP tool invocations for auditing. Use Azure Monitor and Log Analytics.

Clean up resources

If you don't plan to continue using this application, delete the resource group to remove all the resources that you created in this tutorial:

az group delete --resource-group $RESOURCE_GROUP --yes --no-wait

Next step