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.Mcphost 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 coreModelContextProtocolpackage gives you the server primitives and attributes, butWithHttpTransport()andMapMcp()— the methods that let Claude Code reach the server over HTTP — live in the.AspNetCorepackage. 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);
}
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 listshowsbooktrackerconnected, and Claude Code invokes a tool against the DB. -
get_reading_progressreturns 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.