C8 — Build a C# MCP Server for BookTracker

Summary — what this page covers In Day 1 Lab 3 you used GitHub's MCP server from Claude Code. Now you flip it around and build one: a new BookTracker.Mcp host that exposes BookTracker's services as MCP tools, then connect Claude Code to it and watch it query your real database.

Time: ~45 minutes · Format: hands-on, solo · You start from: checkpoint/c7-rag · You end at: checkpoint/c8-mcp-server

MCP (Model Context Protocol) is how an AI host like Claude Code talks to your tools. A server you build is a thin governed gateway: Claude never touches the database — it calls your tools, which run your validated Core services. The payoff in this lab is the through-line from C4: the same IReadingProgressService you wrote on Day 1 becomes reachable a third way — first as an HTTP endpoint (C4), then as an in-process agent tool (C6), and now as an MCP tool any host can call (C8). One service, three surfaces.

Prerequisites: a working local checkout you can run, and the Claude Code CLI installed and signed in (paid Claude plan). This lab starts from the C7 checkpoint.


1. Start from the C7 checkpoint

Each lab begins where the previous one ended. Start a working branch from checkpoint/c7-rag, then move into the solution folder:

git switch -c my-c8 checkpoint/c7-rag
cd src/BookTracker

The matching tag, checkpoint/c8-mcp-server, is the answer key. If you get stuck, diff your work against it — but try the build yourself first.


2. Create the BookTracker.Mcp project

The MCP server is a separate host from BookTracker.Api — its own ASP.NET Core web project that references Core + Data and reuses the same services. Create it as a web SDK project and add it to the solution:

dotnet new web -n BookTracker.Mcp
dotnet sln BookTracker.sln add BookTracker.Mcp/BookTracker.Mcp.csproj

dotnet add BookTracker.Mcp reference BookTracker.Core/BookTracker.Core.csproj
dotnet add BookTracker.Mcp reference BookTracker.Data/BookTracker.Data.csproj

The .csproj keeps the web SDK (Sdk="Microsoft.NET.Sdk.Web") and targets net10.0 — it references Core + Data only, never the Api. The dependency direction stays intact: Mcp → Core + Data, just like the Api.


3. Add the MCP SDK packages

You need two packages — the core SDK and the ASP.NET Core HTTP transport. Both are at the GA 1.x line:

dotnet add BookTracker.Mcp package ModelContextProtocol --version 1.4.0
dotnet add BookTracker.Mcp package ModelContextProtocol.AspNetCore --version 1.4.0

Don't skip ModelContextProtocol.AspNetCore. The core ModelContextProtocol package gives you the server primitives and attributes, but WithHttpTransport() and MapMcp() — the methods that let Claude Code reach the server over HTTP — live in the .AspNetCore package. You need both.


4. Wire up Program.cs — the MCP host

Register the MCP server, reuse the BookTracker stack against the same SQLite database, migrate on startup, and map the MCP endpoint:

using BookTracker.Core.Services;
using BookTracker.Data;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// MCP server over Streamable HTTP; tools are discovered from [McpServerTool] methods in this assembly.
builder.Services.AddMcpServer()
    .WithHttpTransport()
    .WithToolsFromAssembly();

// Reuse the BookTracker stack against the same DB. AddBookTrackerData registers the DbContext +
// repositories; the Core services are registered here just as the API host does.
var connectionString = builder.Configuration.GetConnectionString("BookTracker")
    ?? "Data Source=booktracker.db";
builder.Services.AddBookTrackerData(connectionString);
builder.Services.AddScoped<IBookService, BookService>();
builder.Services.AddScoped<IAuthorService, AuthorService>();
builder.Services.AddScoped<IReadingProgressService, ReadingProgressService>();

var app = builder.Build();

// Migrate at startup so the server runs straight from a clone.
using (var scope = app.Services.CreateScope())
    scope.ServiceProvider.GetRequiredService<BookTrackerDbContext>().Database.Migrate();

app.MapMcp();
app.Run();

Copy ConnectionStrings:BookTracker (Data Source=booktracker.db) into BookTracker.Mcp/appsettings.json so the server points at the same database as the Api.


5. Give it its own port

The Api already uses ~5000/5255. Run the MCP server on its own port — 5100 — so the two hosts don't collide. Set it in BookTracker.Mcp/Properties/launchSettings.json:

{
  "profiles": {
    "http": {
      "commandName": "Project",
      "applicationUrl": "http://localhost:5100",
      "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }
    }
  }
}

Or override it ad hoc: dotnet run --project BookTracker.Mcp --urls http://localhost:5100.


6. Define the tools — Tools/BookMcpTools.cs

Tools are attributed methods on a [McpServerToolType] class. Services are DI-injected straight into the tool method — the SDK resolves them per call. The [Description] is the contract Claude reads to decide when and how to call a tool, so write it like a skill description. Define three tools, validating inputs before they reach the data layer:

using System.ComponentModel;
using System.Text.Json;
using BookTracker.Core.Dtos;
using BookTracker.Core.Services;
using ModelContextProtocol.Server;

namespace BookTracker.Mcp.Tools;

[McpServerToolType]
public class BookMcpTools
{
    [McpServerTool]
    [Description("Search BookTracker for books whose title matches a query. Returns matching books as JSON.")]
    public async Task<string> SearchBooks(
        IBookService books,
        [Description("Text to match against book titles")] string query,
        CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(query))
            return "Error: query is required.";
        return Serialize(await books.SearchAsync(query, ct));
    }

    [McpServerTool]
    [Description("Get a book's reading progress (page, status, percent complete) by book id.")]
    public async Task<string> GetReadingProgress(
        IReadingProgressService progress,
        [Description("The book id")] int bookId,
        CancellationToken ct)
        // Reuses the C4 service — its business rules hold over MCP just as over HTTP.
        => Serialize(await progress.GetForBookAsync(bookId, ct));

    [McpServerTool]
    [Description("Add a new book to BookTracker. The author must already exist. Returns the created book as JSON.")]
    public async Task<string> AddBook(
        IBookService books,
        [Description("Book title")] string title,
        [Description("Id of an existing author")] int authorId,
        [Description("Total page count")] int totalPages,
        [Description("ISBN (optional)")] string? isbn,
        [Description("Genre (optional)")] string? genre,
        CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(title))
            return "Error: title is required.";

        // CreateAsync returns a BookMutationResult; check its status before returning the book.
        var result = await books.CreateAsync(new CreateBookRequest(title, authorId, isbn, totalPages, genre), ct);
        return result.Status == BookMutationStatus.Success
            ? Serialize(result.Book)
            : $"Error: author {authorId} does not exist.";
    }

    private static string Serialize(object? value) => JsonSerializer.Serialize(value);
}
Tool Calls (reused service)
search_books IBookService.SearchAsync
get_reading_progress IReadingProgressService.GetForBookAsync (C4)
add_book IBookService.CreateAsync

Reuse, don't reimplement. Each tool delegates to the same validated Core service the HTTP API uses — no new data access lives in the MCP project.


7. Add the security stubs

A gateway is only as safe as its rules. Add two small teaching stubs under Security/.

Security/McpAuth.cs — a bearer-token middleware that is a no-op unless Mcp:ApiKey is configured, so the server runs locally without setup but teaches the pattern:

namespace BookTracker.Mcp.Security;

public class McpAuth
{
    private readonly RequestDelegate _next;
    private readonly string? _apiKey;

    public McpAuth(RequestDelegate next, IConfiguration configuration)
    {
        _next = next;
        _apiKey = configuration["Mcp:ApiKey"];
    }

    public async Task InvokeAsync(HttpContext context)
    {
        if (!string.IsNullOrEmpty(_apiKey)
            && context.Request.Headers.Authorization.ToString() != $"Bearer {_apiKey}")
        {
            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
            await context.Response.WriteAsync("Unauthorized: missing or invalid bearer token.");
            return;
        }
        await _next(context);
    }
}

Security/AuditLogger.cs — logs every tool invocation so you can answer "what did the agent do?":

namespace BookTracker.Mcp.Security;

public class AuditLogger
{
    private readonly ILogger<AuditLogger> _logger;
    public AuditLogger(ILogger<AuditLogger> logger) => _logger = logger;

    public void ToolInvoked(string toolName, string argumentsSummary)
        => _logger.LogInformation("MCP tool invoked: {Tool} args=({Args})", toolName, argumentsSummary);
}

Register AuditLogger (builder.Services.AddScoped<AuditLogger>();) and add the middleware (app.UseMiddleware<McpAuth>(); before app.MapMcp();). You can then inject AuditLogger into each tool method and call audit.ToolInvoked(...) at the top — the answer-key tag does exactly this.

Build it:

dotnet build BookTracker.sln

8. Connect Claude Code and call a tool

Run the server, then register it with Claude Code as an HTTP MCP server:

dotnet run --project BookTracker.Mcp --urls http://localhost:5100   # leave this running

# in another terminal, from src/BookTracker:
claude mcp add --transport http booktracker http://localhost:5100
claude mcp list                                                     # booktracker - connected

Now ask Claude Code something that needs your tools:

Search BookTracker for books about space

Watch search_books run against the real database and return seeded books. Then prove the through-line:

What is the reading progress for book 1?

get_reading_progress calls the same C4 service — the one already exposed as a C4 HTTP endpoint and a C6 agent tool — now reachable over MCP. One service, three surfaces.


Checkpoint — you're done when:

  • BookTracker.Mcp (web SDK, refs Core + Data) builds and runs on its own port (5100).
  • ModelContextProtocol + ModelContextProtocol.AspNetCore (both 1.4.0) referenced; WithHttpTransport() + MapMcp() wired.
  • 3+ tools with typed params and clear [Description]s, each calling a real Core service; inputs validated.
  • claude mcp list shows booktracker connected, and Claude Code invokes a tool against the DB.
  • get_reading_progress returns the C4 service's data (honoring its rules).
  • Auth (McpAuth) and audit (AuditLogger) stubs are present.

Tag or compare your work against checkpoint/c8-mcp-server.


What's next

Lab 5 (C8 → C9): you'll have Claude generate tests for the MCP server and the rest of the solution, then wire up CI/CD so the whole thing — Api and MCP server alike — builds and tests on every push.