Start Debugging

How to Build a Custom MCP Server in C# on .NET 11

Build a working Model Context Protocol server in C# 14 / .NET 11 using the official ModelContextProtocol 1.2 SDK. Covers stdio transport, [McpServerTool] attributes, dependency injection, the stderr logging trap, and registration with Claude Code, Claude Desktop, and VS Code.

If your team’s source of truth lives in a .NET service — an EF Core database, an internal API, a Hangfire job runner, a Workspace API — then exposing it to a coding agent through the Model Context Protocol is faster than the dotnet-shop side of the internet usually advertises. The official C# SDK hit 1.0 on March 5 2026 and shipped 1.2.0 on March 27, both maintained jointly by Microsoft and Anthropic. The boilerplate is now small enough that the interesting work is in your tool methods, not the protocol plumbing.

This guide builds a real, runnable MCP server in C# 14 on .NET 11, using the ModelContextProtocol 1.2.0 package against the 2025-11-25 MCP specification. By the end you will have an inventory-mcp server that exposes a SQLite database to an agent through three tools, with proper dependency injection, the stderr logging trick the docs only mention in passing, and the exact configuration snippets for Claude Code, Claude Desktop, and VS Code’s mcp.json.

When the C# SDK is the right call

The Anthropic and MCP teams ship official SDKs in TypeScript, Python, and C#. They produce identical wire traffic, so the question is not “which transports the protocol best” but “where does the code I want to expose already live.” Two cases where C# wins:

For background on the protocol itself, the older Microsoft mcp wiring overview for .NET 10 covers the contract-first mindset; this post is the concrete how-to update for .NET 11 and the post-1.0 SDK.

Project setup with the .NET 11 SDK

You need the .NET 11 SDK (dotnet --version should report 11.0.x or later). The ModelContextProtocol 1.2.0 package targets net8.0 and up, so net11.0 is supported and gets you C# 14 features for free.

# .NET 11 SDK, ModelContextProtocol 1.2.0
dotnet new console -n InventoryMcp
cd InventoryMcp
dotnet add package ModelContextProtocol --version 1.2.0
dotnet add package Microsoft.Extensions.Hosting --version 11.0.0
dotnet add package Microsoft.Data.Sqlite --version 11.0.0

The packages split looks like this, and the choice matters:

For a stdio server you launch from a coding agent, only the first one is needed.

The .csproj for .NET 11 ends up minimal:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net11.0</TargetFramework>
    <LangVersion>14.0</LangVersion>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <RootNamespace>InventoryMcp</RootNamespace>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="ModelContextProtocol" Version="1.2.0" />
    <PackageReference Include="Microsoft.Extensions.Hosting" Version="11.0.0" />
    <PackageReference Include="Microsoft.Data.Sqlite" Version="11.0.0" />
  </ItemGroup>
</Project>

The Program.cs that does not corrupt stdout

Stdio transport carries JSON-RPC messages on the process’s stdin/stdout pair. The server reads requests on stdin and writes responses on stdout. Anything else that touches stdout — a stray Console.WriteLine, a default-configured ILogger emitting to stdout, an exception’s stack trace landing on stdout instead of stderr — gets injected into the JSON stream and the client kills the connection with a parse error.

The C# SDK’s hosting integration handles the protocol writes, but you have to rebind the console logger to stderr or you will lose the first 30 minutes of your life chasing “MCP server disconnected” alerts in Claude Code:

// Program.cs, .NET 11, ModelContextProtocol 1.2.0
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Data.Sqlite;
using InventoryMcp;

var builder = Host.CreateApplicationBuilder(args);

// All log output goes to stderr. Stdout is reserved for MCP traffic.
builder.Logging.AddConsole(o =>
{
    o.LogToStandardErrorThreshold = LogLevel.Trace;
});

builder.Services.AddSingleton<ProductRepository>(_ =>
{
    var dbPath = Environment.GetEnvironmentVariable("INVENTORY_DB_PATH")
                 ?? Path.Combine(AppContext.BaseDirectory, "inventory.db");
    return new ProductRepository($"Data Source={dbPath}");
});

builder.Services
    .AddMcpServer()
    .WithStdioServerTransport()
    .WithToolsFromAssembly();

await builder.Build().RunAsync();

Three things worth pinning down:

Defining the tools

Each tool is a method on a class decorated with [McpServerToolType]. The method itself carries [McpServerTool, Description("...")]. Method parameters become the input schema; [Description] on each parameter ends up in the JSON Schema the agent sees when it decides whether to call the tool.

The repository is plain ADO.NET with Microsoft.Data.Sqlite so the example reads end to end without an ORM dance. The pattern works the same with EF Core 11 — inject the DbContext and the registration loop is identical:

// ProductRepository.cs, .NET 11
using Microsoft.Data.Sqlite;

namespace InventoryMcp;

public sealed record Product(string Sku, string Name, int Stock, decimal Price);

public sealed class ProductRepository
{
    private readonly string _connectionString;

    public ProductRepository(string connectionString)
    {
        _connectionString = connectionString;
        EnsureSchema();
    }

    public IReadOnlyList<Product> List(bool lowStockOnly, int limit)
    {
        using var conn = new SqliteConnection(_connectionString);
        conn.Open();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = lowStockOnly
            ? "SELECT sku, name, stock, price FROM products WHERE stock < 10 ORDER BY name LIMIT $limit"
            : "SELECT sku, name, stock, price FROM products ORDER BY name LIMIT $limit";
        cmd.Parameters.AddWithValue("$limit", limit);

        var results = new List<Product>();
        using var reader = cmd.ExecuteReader();
        while (reader.Read())
        {
            results.Add(new Product(
                reader.GetString(0),
                reader.GetString(1),
                reader.GetInt32(2),
                reader.GetDecimal(3)));
        }
        return results;
    }

    public Product? Get(string sku)
    {
        using var conn = new SqliteConnection(_connectionString);
        conn.Open();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = "SELECT sku, name, stock, price FROM products WHERE sku = $sku";
        cmd.Parameters.AddWithValue("$sku", sku);

        using var reader = cmd.ExecuteReader();
        return reader.Read()
            ? new Product(reader.GetString(0), reader.GetString(1), reader.GetInt32(2), reader.GetDecimal(3))
            : null;
    }

    public int Adjust(string sku, int delta)
    {
        using var conn = new SqliteConnection(_connectionString);
        conn.Open();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = """
            UPDATE products SET stock = stock + $delta
            WHERE sku = $sku AND stock + $delta >= 0
            RETURNING stock
            """;
        cmd.Parameters.AddWithValue("$sku", sku);
        cmd.Parameters.AddWithValue("$delta", delta);

        var result = cmd.ExecuteScalar();
        if (result is null)
        {
            throw new InvalidOperationException(
                $"Cannot adjust stock for SKU '{sku}': product not found or stock would go negative.");
        }
        return Convert.ToInt32(result);
    }

    private void EnsureSchema() { /* CREATE TABLE IF NOT EXISTS ... and seed */ }
}

The tool class is the surface the agent sees:

// InventoryTools.cs, ModelContextProtocol 1.2.0
using System.ComponentModel;
using ModelContextProtocol.Server;

namespace InventoryMcp;

[McpServerToolType]
public sealed class InventoryTools
{
    private readonly ProductRepository _repo;
    private readonly ILogger<InventoryTools> _logger;

    public InventoryTools(ProductRepository repo, ILogger<InventoryTools> logger)
    {
        _repo = repo;
        _logger = logger;
    }

    [McpServerTool, Description("List products in the inventory database. Optionally filter to low-stock items (under 10 units).")]
    public IReadOnlyList<Product> ListProducts(
        [Description("If true, return only products with fewer than 10 units in stock.")] bool lowStockOnly = false,
        [Description("Maximum number of rows to return. Default 50, hard cap 500.")] int limit = 50)
    {
        limit = Math.Clamp(limit, 1, 500);
        return _repo.List(lowStockOnly, limit);
    }

    [McpServerTool, Description("Get a single product by its SKU. Returns null if no product matches.")]
    public Product? GetProduct(
        [Description("Stock-keeping unit, e.g. 'SKU-001'. Case-sensitive exact match.")] string sku)
        => _repo.Get(sku);

    [McpServerTool, Description("Adjust stock for a SKU by a positive or negative delta. Returns the new stock level. Errors if the SKU does not exist or the result would be negative.")]
    public int AdjustStock(
        [Description("SKU to adjust, e.g. 'SKU-001'.")] string sku,
        [Description("Signed integer delta. Use positive numbers to receive stock, negative to ship.")] int delta)
    {
        _logger.LogInformation("AdjustStock sku={Sku} delta={Delta}", sku, delta);
        return _repo.Adjust(sku, delta);
    }
}

A few details that matter once an agent actually starts calling this:

Wiring it to Claude Code, Claude Desktop, and VS Code

Once dotnet run starts the process, register it with the agent. Three formats, same binary.

Claude Code has a built-in command for stdio servers. From the project root:

# Claude Code 2.x
claude mcp add inventory -- dotnet run --project ./InventoryMcp.csproj

For a published build, swap to the binary:

dotnet publish -c Release -o publish
claude mcp add inventory -- ./publish/InventoryMcp

Claude Desktop uses claude_desktop_config.json. On Windows it lives at %AppData%\Claude\claude_desktop_config.json; on macOS at ~/Library/Application Support/Claude/claude_desktop_config.json:

{
  "mcpServers": {
    "inventory": {
      "command": "dotnet",
      "args": [
        "run",
        "--project",
        "C:\\src\\InventoryMcp\\InventoryMcp.csproj",
        "--no-launch-profile"
      ],
      "env": {
        "INVENTORY_DB_PATH": "C:\\data\\inventory.db"
      }
    }
  }
}

Restart Claude Desktop, and the MCP indicator should list list_products, get_product, and adjust_stock. Ask it “Which products are low on stock?” and watch it call list_products(lowStockOnly: true).

VS Code uses .vscode/mcp.json for workspace-scoped servers:

{
  "inputs": [],
  "servers": {
    "inventory": {
      "type": "stdio",
      "command": "dotnet",
      "args": ["run", "--project", "${workspaceFolder}/InventoryMcp/InventoryMcp.csproj"]
    }
  }
}

For a sense of how an IDE bundles MCP servers natively rather than going through user config, the Azure MCP Server inside Visual Studio 2022 17.14.30 is a useful reference point.

When stdio is wrong: the HTTP transport shape

Stdio is correct for “agent on my machine, server on my machine, one client per process.” The moment you want a long-lived server other developers connect to remotely, swap the package and the registration:

// dotnet add package ModelContextProtocol.AspNetCore --version 1.2.0
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ProductRepository>(/* ... */);
builder.Services
    .AddMcpServer(o => o.ServerInfo = new() { Name = "inventory", Version = "1.0.0" })
    .WithHttpTransport()
    .WithToolsFromAssembly();

var app = builder.Build();
app.MapMcp();
app.Run();

MapMcp() exposes the streamable-HTTP and SSE endpoints the spec defines. Put it behind your usual ASP.NET Core auth pipeline and you get OAuth 2.0 incremental scope consent, well-known authorization discovery, and the long-running request polling that landed in the 1.0 release for free.

Production gotchas the docs underplay

Don’t ship a single Microsoft.Data.Sqlite connection. The above example opens a fresh connection per call, which is the right default for an SDK demo. For workloads beyond a hobby database, register SqliteConnection as a transient service or wire EF Core 11 with pooling. SQLite serialises writes by default; if two AdjustStock calls fire simultaneously you will see SQLITE_BUSY once the lock contention crosses a few hundred ms.

Cancellation tokens. Tool methods can take a trailing CancellationToken parameter and the SDK will plumb the per-request token in. If your tool calls HttpClient, EF Core, or any I/O, accept the token and pass it through. Otherwise a misbehaving model that times out leaves a SQLite transaction or HTTP request hanging on the server.

IHttpClientFactory for outbound calls. When a tool fetches from an external API, inject IHttpClientFactory and create named clients. The same lifetime rules that bite ASP.NET Core apps — socket exhaustion from new HttpClient(), DNS pinning — bite MCP servers harder, because they tend to stay running across many agent sessions.

Logging volume. A chatty LogInformation per tool call is fine. Logging the entire tool input on every call leaks PII into stderr and ends up in Claude Code’s transcript, which the user may not realise is being captured. Treat tool-call logs the same way you treat web-request logs: redact secrets, summarise inputs.

JSON serialisation surprises. The SDK uses System.Text.Json with the default options. If your domain types rely on Newtonsoft.Json attributes or non-default casing, configure the JSON options on the host or convert to plain records at the tool boundary. A type that serialises one way to your REST clients and another way to MCP clients is a debugging nightmare.

Native AOT. The ModelContextProtocol package is not fully AOT-friendly yet because the attribute-driven tool discovery uses reflection. If you need a single-file AOT executable for distribution, use ModelContextProtocol.Core and register tools manually with MapTool instead of WithToolsFromAssembly.

What this pattern unlocks for a .NET shop

The core move — decorate a method, return a record, throw on errors — scales to every C# integration your team already has. A few obvious next steps:

If you primarily work in another language, the equivalent server in TypeScript that wraps a CLI covers Node.js with @modelcontextprotocol/sdk, and the Python guide using the official mcp SDK covers the FastMCP pattern. And if you are looking past MCP into multi-agent orchestration in C#, Microsoft Agent Framework 1.0 picks up where MCP leaves off, with planners, multi-agent handoff, and durable run state.

The MCP server itself does not care whether your tool wraps a SQLite database, a SignalR hub, or a 500-line domain service. It only needs typed parameters (the C# attributes give you that for free), a return value the SDK can serialise, and a stdio stream that does not have stray bytes in it.

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back