Build Your First MCP Server in .NET with EF Core

Introduction
Claude Code and GitHub Copilot can refactor a codebase, explain a gnarly LINQ query, even scaffold a whole feature. But neither has any idea what's actually in your database, your Azure resources, your Confluence pages or your internal APIs until you give them a way to ask. That's what MCP (Model Context Protocol) is for. In this post you build a local MCP server in .NET with EF Core from an empty folder: the protocol basics, a real SQL Server database wired up through EF Core, three tools an AI model can call against it, and Claude using your own code and data by the end.
This is Part 1 of the MCP Server in .NET series - Tools first, Resources and Prompts next.
🎬 Watch the full video here:
What MCP actually is
MCP is an open protocol that lets an AI client discover and use whatever a server exposes. Clients like Claude Desktop, Claude Code and GitHub Copilot send JSON-RPC requests to the server - over stdio when it runs locally, as in this example, or over HTTP when it's hosted remotely.
A .NET MCP server can expose three kinds of things:
- Tools - functions the model invokes on its own once the initial handshake tells it what's available. This post focuses on tools.
- Resources - read-only content the server serves for a client to attach to a conversation. An on-call runbook is a good example: attach it once and the model suddenly knows a procedure it didn't before.
- Prompts - predefined templates a client exposes, typically triggered with a slash command plus a parameter.
Resources and Prompts get their own episodes later in the series.
Scaffolding the server skeleton
The project starts as the plainest possible .NET project - a console app:
dotnet new console -n MyFirstMcpServer
cd MyFirstMcpServer
dotnet add package ModelContextProtocol
dotnet add package Microsoft.Extensions.Hosting
Program.cs builds on the generic host - the same plumbing behind ASP.NET Core apps and background services:
var builder = Host.CreateApplicationBuilder(args);
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithToolsFromAssembly();
var host = builder.Build();
await host.RunAsync();
AddMcpServer()turns on the core server functionality.WithStdioServerTransport()registers the piece that reads requests from standard input and writes responses to standard output. The server runs locally, launched as a child process by whatever client connects to it.WithToolsFromAssembly()scans the project by reflection for every class carrying a specific attribute and registers each qualifying method as something a client can call. It also wires those tool classes into dependency injection.
One detail worth knowing before it bites you: because stdio hands the entire standard-output stream to the protocol, the default console logger has to be redirected to standard error, or a stray log line corrupts the JSON-RPC conversation. Do this in Program.cs before any tool logs anything.
Wiring up EF Core
A tool that returns nothing interesting doesn't prove much, so the next step adds a real SQL Server database: an EF Core Book entity, a LibraryDbContext with one DbSet<Book>, and an InitialCreate migration that creates the Books table. A LibrarySeeder class uses the Bogus library to check whether the table is empty and, if so, generate 5,000 realistic-looking fake books.
Program.cs registers the context against a connection string read from appsettings.json, and applies the migration and runs the seeder right after the host builds, before it accepts any calls:
builder.Services.AddDbContext<LibraryDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("LibraryDb")));
// ...
using (var scope = host.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<LibraryDbContext>();
await db.Database.MigrateAsync();
await LibrarySeeder.SeedAsync(db);
}
Reading builder.Configuration directly here, instead of the more typical IOptions<T> pattern, is unavoidable - AddDbContext has to run before the container exists.
Three tools, three different query shapes
LibraryTools is marked [McpServerToolType] - the attribute WithToolsFromAssembly() scans for - and each callable method carries [McpServerTool] with a Description. That description is the entire piece of information the model uses to pick the right tool:
[McpServerToolType]
public class LibraryTools(LibraryDbContext db, ILogger<LibraryTools> logger)
{
[McpServerTool, Description("Searches the library catalog by title or author and returns up to 10 matching books.")]
public async Task<string> SearchBooks(
[Description("Part of a book title or author name to search for.")] string searchTerm)
{
var matches = await db.Books
.Where(b => EF.Functions.Like(b.Title, $"%{searchTerm}%") ||
EF.Functions.Like(b.Author, $"%{searchTerm}%"))
.OrderBy(b => b.Title)
.Take(10)
.ToListAsync();
// ...
}
}
SearchBooksruns a fuzzyLIKEmatch across title and author.GetBooksByGenreruns an exact-match filter on one column.GetLibraryStatstakes no parameters and returns an aggregate - total books, total copies and a genre breakdown computed entirely inside SQL Server withGroupBy/Select/OrderByDescending.
Three genuinely different query shapes, so the model has a real choice to make. The class isn't static - WithToolsFromAssembly() creates a fresh instance per call, resolving LibraryDbContext and ILogger<T> from DI automatically.
Publishing as a single self-contained executable
Rather than pointing an AI client at dotnet run, publish the server as a self-contained, single-file executable for win-x64 - deployment mode "self-contained", target runtime x64, "produce single file" checked. The result is one .exe with everything bundled in.
Wiring it into Claude Desktop
Claude Desktop's config file (Settings → Developer → Edit Config) gets one new entry pointing straight at that executable:
{
"mcpServers": {
"my-first-mcp-server": {
"command": "C:/path/to/MyFirstMcpServer.exe",
"args": []
}
}
}
Claude Desktop only reads this file on startup, so a config change means fully quitting and relaunching the app, not just starting a new chat.
With the server connected, asking a few different questions in one conversation shows Claude choosing correctly between the three tools based only on their descriptions:
- "How many books do we have in total, and what's the breakdown by genre?" → Claude calls
get_library_statswith no arguments and returns real numbers straight from the database. - "What Fantasy books do we have?" → Claude calls
get_books_by_genrewithgenre: "Fantasy"filled in on its own, returning a real top 10 out of several hundred Fantasy titles. - "Can you check the catalog for anything by [an author from the seed data]?" → Claude calls
search_bookswith the author's name, and the matches line up exactly with what's in the database.
Testing locally with MCP Inspector
Before wiring a server into an AI client, verify it independently with the MCP Inspector:
npx @modelcontextprotocol/inspector
Add the server manually (stdio transport, the path to the published .exe, and a working directory matching where appsettings.json lives). The Inspector lists every tool, lets you fill in parameters by hand and shows the raw result - a much easier way to catch a problem than debugging it live inside an AI client.
MCP pitfalls
- Standard-output logging corrupts the stdio transport - redirect the console logger to stderr before any tool logs anything.
- Claude Desktop config changes need a full restart, not just a new conversation.
- A migration has to actually be applied - registering a
DbContextdoesn't create a schema by itself. - Tool and parameter descriptions are the model's only signal - vague or overlapping descriptions are how it picks the wrong tool, or none at all.
- A self-contained executable still needs its working directory set correctly wherever
appsettings.jsonlives.
Key Takeaways
- MCP is an open protocol, not magic - AI clients discover and call whatever Tools, Resources or Prompts a server exposes, over stdio locally or HTTP remotely.
- Tools are functions the model decides to call on its own, based entirely on their descriptions.
[McpServerToolType]and[McpServerTool, Description(...)]make a class and its methods discoverable. - Test an MCP server with the MCP Inspector before trusting an AI client to drive it for the first time.
- Publishing as a self-contained single-file executable and pointing an AI client's config at it is a clean way to hand off a finished local server.
Get the Full Source Code
The complete MCP server solution - the EF Core model, seeder and the three tools - is available to Patreon supporters. If you want it ready to run instead of rebuilding it from the walkthrough above, you can find it on Patreon.