Build an MCP server in C# and plug it into Claude Code

C# MCP server plugged into Claude Code

Build an MCP server in C# and plug it into Claude Code

Part 3 in the “Agent Skills” series: Part 1 – What an agent skill is | Part 2 – Create your own skill | Version française

In the first article and the second one of this series, we saw what a skill is and how to create one. A skill gives instructions to the agent, but it doesn’t give it access to new data or new systems. For that, you need tools, and that’s the role of MCP.

In this article, we build a C# MCP server that exposes a small internal API, we plug it into Claude Code, and we finish with the difference between an MCP server and a skill.

The full code is available here: mongeon/code-examples · mcp-books-claude-code.

MCP in a nutshell

MCP (Model Context Protocol) is a standard protocol (JSON-RPC 2.0) between AI clients and tool servers. The server exposes three kinds of primitives: tools (functions the agent can call), resources (data to inject into the context) and prompts (reusable templates). The client discovers them, and the model decides when to use them.

The advantage is that you write the server only once and it works with every client: Claude Code, Claude Desktop, VS Code Copilot, Cursor. I already covered the SDK details in my post about the MCP weather server, plugged into Claude Desktop. Here, I focus on how to wrap an existing backend and on the Claude Code integration.

The internal API: a reading tracker

For the example, here’s a small reading tracker API built as a Minimal API, with three endpoints and in-memory storage.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var books = new List<Book>
{
    new(1, "Clean Code", "Robert C. Martin", "done"),
    new(2, "The Pragmatic Programmer", "Hunt and Thomas", "reading"),
    new(3, "Working Effectively with Legacy Code", "Michael Feathers", "to-read")
};

app.MapGet("/books", () => books);

app.MapGet("/books/search", (string q) =>
    books.Where(b => b.Title.Contains(q, StringComparison.OrdinalIgnoreCase)
                  || b.Author.Contains(q, StringComparison.OrdinalIgnoreCase)));

app.MapPost("/books", (Book book) =>
{
    var created = book with { Id = books.Max(b => b.Id) + 1 };
    books.Add(created);
    return Results.Created($"/books/{created.Id}", created);
});

app.Run("http://localhost:5200");

public record Book(int Id, string Title, string Author, string Status);

In a real project, this could be your internal API or any existing service. The important part is that the backend already exists with its own logic: the MCP server will simply expose it.

The MCP server on top of the API

The MCP server is a second console project, using the official SDK co-maintained by Anthropic and Microsoft:

dotnet add package ModelContextProtocol
dotnet add package Microsoft.Extensions.Hosting
dotnet add package Microsoft.Extensions.Http

The SDK has reached a stable release; I still recommend pinning the version in your .csproj to avoid surprises.

The Program.cs looks a lot like the one from the weather post:

var builder = Host.CreateApplicationBuilder(args);

// Logs go to stderr, stdout is reserved for the MCP transport (JSON-RPC)
builder.Logging.AddConsole(opts =>
{
    opts.LogToStandardErrorThreshold = LogLevel.Trace;
});

builder.Services.AddHttpClient<BooksClient>(client =>
{
    client.BaseAddress = new Uri("http://localhost:5200");
});

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

await builder.Build().RunAsync();

BooksClient is a typed HTTP client, standard ASP.NET Core:

public class BooksClient(HttpClient httpClient)
{
    public Task<List<Book>?> GetAllAsync() =>
        httpClient.GetFromJsonAsync<List<Book>>("/books");

    public Task<List<Book>?> SearchAsync(string query) =>
        httpClient.GetFromJsonAsync<List<Book>>(
            $"/books/search?q={Uri.EscapeDataString(query)}");

    public async Task<Book?> AddAsync(string title, string author, string status)
    {
        var response = await httpClient.PostAsJsonAsync("/books",
            new Book(0, title, author, status));
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<Book>();
    }
}

public record Book(int Id, string Title, string Author, string Status);

And the tools themselves, discovered automatically by WithToolsFromAssembly():

[McpServerToolType]
public static class BooksTools
{
    [McpServerTool]
    [Description("Lists every book in the personal reading tracker, with author and status.")]
    public static async Task<string> ListBooks(BooksClient client)
    {
        var books = await client.GetAllAsync();
        if (books is null || books.Count == 0)
            return "The reading list is empty.";

        return string.Join("\n",
            books.Select(b => $"- {b.Title} ({b.Author}): {b.Status}"));
    }

    [McpServerTool]
    [Description("Searches the reading tracker by book title or author name.")]
    public static async Task<string> SearchBooks(
        BooksClient client,
        [Description("Text to look for in the title or author (e.g. 'legacy', 'Martin')")]
        string query)
    {
        var books = await client.SearchAsync(query);
        if (books is null || books.Count == 0)
            return $"No book matches '{query}'.";

        return string.Join("\n",
            books.Select(b => $"- {b.Title} ({b.Author}): {b.Status}"));
    }

    [McpServerTool]
    [Description("Adds a book to the personal reading tracker.")]
    public static async Task<string> AddBook(
        BooksClient client,
        [Description("Book title")] string title,
        [Description("Author name")] string author,
        [Description("Reading status: 'to-read', 'reading' or 'done'")] string status)
    {
        var book = await client.AddAsync(title, author, status);
        if (book is null)
            return "The book could not be added.";

        return $"Added '{book.Title}' by {book.Author} with status '{book.Status}'.";
    }
}

Notice there is no business logic here: each tool calls the API and formats the response for the model. The BooksClient gets injected automatically from the DI container, and the [Description] attributes are what the model reads to decide which tool to call and what to pass it. It’s the same principle as a skill’s description from part 2: if the description is vague, the tool won’t be used.

Careful: nothing must go to stdout except the JSON-RPC. A Console.WriteLine inside a tool will break the transport, which is why the logs are configured to go to stderr.

Plugging the server into Claude Code

In your project folder, a single command is enough:

claude mcp add books -- dotnet run --project ./BooksMcp

Everything after the -- is the command Claude Code will launch as a child process, with stdin/stdout as the JSON-RPC channel. No port to open for the MCP server itself; only the internal API listens on a port, and you start it separately with a dotnet run in another terminal.

By default, the config uses the local scope: it applies to you, in that project. Two other scopes exist:

  • --scope user: the server becomes available in all your projects
  • --scope project: the config gets written to a .mcp.json file at the root of the repo, which you commit for the whole team

The generated .mcp.json uses the same format as Claude Desktop:

{
  "mcpServers": {
    "books": {
      "command": "dotnet",
      "args": ["run", "--project", "./BooksMcp"]
    }
  }
}

To check that everything is wired up, type /mcp in Claude Code: the list of servers shows up with their status and their tools. Then test with a real request: “What’s on my reading list right now?” or “Add Refactoring by Martin Fowler to my to-read list”. Claude Code calls ListBooks or AddBook and answers you with the data from your API.

If the server shows up as failed in /mcp, the problem is almost always in the startup command: a relative project path pointing to the wrong place, or the internal API not running.

MCP or skill: when to use which

The difference between the two is fairly simple: a skill gives knowledge to the agent, while an MCP server gives it tools.

A skill is a Markdown file loaded into the context. It tells the agent how to do something with the means it already has: your code conventions, your review process, the structure of your blog posts. There is no code to deploy and you can modify it in a few seconds.

An MCP server is code that runs. It gives the agent access it doesn’t have: your internal API, a database, an external system. It requires a real project, with dependencies and maintenance, but the agent can then query and modify those systems directly.

To choose between the two, I ask myself the following question: could the agent already do it if it knew how? If yes, a skill is enough. Otherwise, you need an MCP server. In our example, Claude Code doesn’t have access to the reading API, so a skill wouldn’t help.

The two also combine very well. In part 2, the github-actions-failure-debugging skill contained no code: it told the agent which GitHub MCP Server tools to use, and in what order. The MCP server provides the tools and the skill explains how to use them.

Resources

Happy coding, and remember to check /mcp if your tools don’t show up in Claude Code.


This post was written with AI assistance and edited by me.


See also