Learnixo
Back to blog
Backend Systemsadvanced

Building an MCP Server in .NET — Model Context Protocol

Build a Model Context Protocol (MCP) server in C# that exposes tools, resources, and prompts to AI assistants like Claude and Copilot. DI integration, streaming, and production hosting.

Asma Hafeez KhanMay 25, 20266 min read
.NETC#MCPModel Context ProtocolAIClaudetools
Share:𝕏

Building an MCP Server in .NET — Model Context Protocol

Model Context Protocol (MCP) is an open standard that lets AI assistants (Claude, GitHub Copilot, Cursor) call your application's tools and read your data. You build an MCP server; the AI client discovers and invokes its capabilities automatically.


What MCP Provides

MCP Server exposes three primitive types:

Tools       — Functions the AI can invoke (like function calling, but standardised)
              Example: "get_order_status", "search_products", "create_ticket"

Resources   — Data the AI can read (files, database records, API data)
              Example: "order://12345", "product-catalogue://electronics"

Prompts     — Reusable prompt templates with parameters
              Example: "summarise-order" template with order_id parameter

Transport options:
  stdio       — subprocess communication (Claude Desktop, local tools)
  HTTP + SSE  — remote server with Server-Sent Events (web deployments)

Step 1: Install the SDK

XML
<PackageReference Include="ModelContextProtocol" Version="0.*" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="0.*" />

Step 2: Define Tools

C#
// Tools are C# methods with [McpServerTool] attribute
// The MCP SDK generates the JSON schema automatically from parameter types

[McpServerToolType]
public class OrderTools(IOrderRepository orders, IInventoryService inventory)
{
    [McpServerTool(Name = "get_order_status")]
    [Description("Get the current status and details of a customer order")]
    public async Task<string> GetOrderStatusAsync(
        [Description("The order ID to look up")] int orderId,
        CancellationToken ct = default)
    {
        var order = await orders.GetByIdAsync(orderId, ct);
        if (order is null) return $"Order {orderId} not found.";

        return $"""
            Order {orderId}:
            Status:    {order.Status}
            Total:     {order.Total:C}
            Items:     {order.Items.Count}
            Created:   {order.CreatedAt:yyyy-MM-dd}
            Customer:  {order.CustomerEmail}
            """;
    }

    [McpServerTool(Name = "search_products")]
    [Description("Search the product catalogue by keyword")]
    public async Task<string> SearchProductsAsync(
        [Description("Search query")] string query,
        [Description("Maximum results to return (default 10)")] int limit = 10,
        CancellationToken ct = default)
    {
        var products = await inventory.SearchAsync(query, limit, ct);

        if (products.Count == 0) return "No products found.";

        return string.Join("\n", products.Select(p =>
            $"- {p.Name} (ID: {p.Id}, Price: {p.Price:C}, Stock: {p.StockLevel})"));
    }

    [McpServerTool(Name = "cancel_order")]
    [Description("Cancel a pending order. Only works for orders in Pending status.")]
    public async Task<string> CancelOrderAsync(
        [Description("Order ID to cancel")] int orderId,
        [Description("Reason for cancellation")] string reason,
        CancellationToken ct = default)
    {
        var success = await orders.CancelAsync(orderId, reason, ct);
        return success
            ? $"Order {orderId} cancelled. Reason: {reason}"
            : $"Cannot cancel order {orderId} — check that it is still in Pending status.";
    }

    [McpServerTool(Name = "create_support_ticket")]
    [Description("Create a customer support ticket")]
    public async Task<CreateTicketResult> CreateTicketAsync(
        [Description("Customer email address")] string customerEmail,
        [Description("Subject of the issue")] string subject,
        [Description("Detailed description")] string description,
        [Description("Priority: Low, Normal, High, Urgent")] string priority = "Normal",
        CancellationToken ct = default)
    {
        var ticket = await orders.CreateTicketAsync(customerEmail, subject, description, priority, ct);
        return new CreateTicketResult(ticket.Id, ticket.Reference, ticket.EstimatedResponseTime);
    }
}

public record CreateTicketResult(int Id, string Reference, string EstimatedResponseTime);

Step 3: Define Resources

C#
// Resources provide structured data the AI can read
[McpServerResourceType]
public class OrderResources(IOrderRepository orders)
{
    // Resource URI: order://12345
    [McpServerResource(UriTemplate = "order://{orderId}")]
    [Description("Full details of a specific order including line items")]
    public async Task<string> GetOrderResourceAsync(
        int orderId,
        CancellationToken ct = default)
    {
        var order = await orders.GetByIdAsync(orderId, ct);
        if (order is null) return $"Order {orderId} not found.";

        // Return rich structured data for the AI to reason over
        return JsonSerializer.Serialize(order, new JsonSerializerOptions { WriteIndented = true });
    }

    // Resource URI: orders://customer/user@example.com
    [McpServerResource(UriTemplate = "orders://customer/{email}")]
    [Description("All orders for a specific customer")]
    public async Task<string> GetCustomerOrdersAsync(
        string email,
        CancellationToken ct = default)
    {
        var orders = await orders.GetByCustomerEmailAsync(email, ct);
        return JsonSerializer.Serialize(orders, new JsonSerializerOptions { WriteIndented = true });
    }
}

Step 4: Define Prompts

C#
// Prompt templates the AI client can use as starting points
[McpServerPromptType]
public class OrderPrompts
{
    [McpServerPrompt(Name = "summarise_order")]
    [Description("Generate a customer-friendly order summary")]
    public ChatMessage[] SummariseOrder(
        [Description("The order ID to summarise")] int orderId)
        => [
            new ChatMessage(ChatRole.User,
                $"Please retrieve and summarise order {orderId} in a customer-friendly format. " +
                $"Include status, estimated delivery, and any issues."),
        ];

    [McpServerPrompt(Name = "investigate_customer")]
    [Description("Investigate all recent orders for a customer")]
    public ChatMessage[] InvestigateCustomer(
        [Description("Customer email address")] string email,
        [Description("Number of recent orders to check")] int count = 5)
        => [
            new ChatMessage(ChatRole.User,
                $"Look up the last {count} orders for customer {email}. " +
                $"Identify any patterns: late deliveries, cancellations, complaints. " +
                $"Provide a brief risk assessment."),
        ];
}

Step 5: Wire Up in ASP.NET Core (HTTP Transport)

C#
// Program.cs — HTTP + SSE transport for remote MCP servers
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IInventoryService, InventoryService>();

// Register MCP server with all tools, resources, prompts
builder.Services
    .AddMcpServer()
    .WithHttpTransport()
    .WithToolsFromAssembly()      // discovers all [McpServerToolType] classes
    .WithResourcesFromAssembly()  // discovers all [McpServerResourceType] classes
    .WithPromptsFromAssembly();   // discovers all [McpServerPromptType] classes

var app = builder.Build();

app.MapMcp("/mcp");   // exposes: GET /mcp (SSE), POST /mcp (messages)

app.Run();

Step 6: stdio Transport (Claude Desktop / Local Tools)

C#
// Program.cs — stdio transport for local MCP servers
// Run as a subprocess; communicate via stdin/stdout

var builder = Host.CreateApplicationBuilder(args);

builder.Services.AddScoped<IOrderRepository, OrderRepository>();

builder.Services
    .AddMcpServer()
    .WithStdioServerTransport()   // reads from stdin, writes to stdout
    .WithToolsFromAssembly();

var host = builder.Build();
await host.RunAsync();
JSON
// claude_desktop_config.json — tell Claude Desktop about your MCP server
{
  "mcpServers": {
    "order-system": {
      "command": "dotnet",
      "args": ["run", "--project", "src/OrderMcp/OrderMcp.csproj"],
      "env": {
        "ConnectionStrings__DefaultConnection": "Host=localhost;Database=orders;Username=dev;Password=dev"
      }
    }
  }
}

Step 7: Calling an MCP Server from .NET

C#
// Your .NET app can also be an MCP client — call other MCP servers
public class McpClientService
{
    public static async Task<string> CallExternalMcpAsync()
    {
        // Connect to an MCP server via stdio
        var clientTransport = new StdioClientTransport(new StdioClientTransportOptions
        {
            Command = "npx",
            Arguments = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
        });

        await using var client = await McpClientFactory.CreateAsync(clientTransport);

        // List available tools
        var tools = await client.ListToolsAsync();
        Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");

        // Call a tool
        var result = await client.CallToolAsync("read_file", new Dictionary<string, object>
        {
            ["path"] = "/tmp/example.txt"
        });

        return result.Content.FirstOrDefault()?.Text ?? "";
    }
}

Step 8: Authentication and Security

C#
// Add API key authentication to the HTTP MCP endpoint
builder.Services.AddAuthentication("ApiKey")
    .AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(
        "ApiKey", _ => { });

builder.Services.AddAuthorization(opts =>
    opts.AddPolicy("Mcp", policy => policy.RequireAuthenticatedUser()));

// In middleware — only allow authenticated callers to the MCP endpoint
app.MapMcp("/mcp").RequireAuthorization("Mcp");
C#
// Tool-level access control
[McpServerTool(Name = "cancel_order")]
[Description("Cancel an order (requires admin role)")]
public async Task<string> CancelOrderAsync(
    int orderId,
    string reason,
    IHttpContextAccessor ctx,   // inject for per-request auth
    CancellationToken ct = default)
{
    var user = ctx.HttpContext?.User;
    if (user?.IsInRole("Admin") != true)
        return "Forbidden: admin role required to cancel orders.";

    return await CancelAsync(orderId, reason, ct);
}

Interview Answer

"MCP (Model Context Protocol) is an open standard for connecting AI assistants to external tools and data. An MCP server exposes three primitives: tools (callable functions), resources (readable data by URI), and prompts (reusable templates). In .NET, the ModelContextProtocol NuGet SDK generates JSON schemas automatically from C# method signatures — you decorate classes with [McpServerToolType] and methods with [McpServerTool(Name = 'tool_name')]. For remote deployment use HTTP + SSE transport via MapMcp('/mcp'); for local tools (Claude Desktop) use stdio transport where the server communicates through stdin/stdout. Security: require API key authentication at the /mcp endpoint with RequireAuthorization(), and enforce role checks inside individual tool methods. The protocol handles the discovery handshake and invocation loop — your code just handles the business logic. Claude Code, Cursor, and GitHub Copilot all support MCP, making it the primary integration point for AI-enabled internal tools."

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.