Connect with me:
🚀 Sponsored
Struggling with slow EF Core operations? Boost performance like never before. Experience up to 14× faster Bulk Insert, Update, Delete, and Merge - and cut your save time by as much as 94%.
👉 entityframework-extensions.net
Introduction
Claude Code and GitHub Copilot can write excellent C#. They can refactor a codebase, explain a gnarly LINQ query, even scaffold a whole feature from a set of requirements. But neither one 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 exactly what MCP (Model Context Protocol) is for. In this issue you build a local MCP server in .NET with EF Core from a completely empty folder: the protocol internals, a real SQL Server database wired up through EF Core, three tools an AI model can call against it, and Claude actually using your own code and your own data by the end.
This is Part 1 of a “Local MCP 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, the way this episode’s server does, or over HTTP when it’s hosted remotely.
A .NET MCP server can expose three kinds of things. Tools are functions the model invokes on its own once the initial handshake tells it what’s available - the focus of this episode. Resources are read-only content the server serves up 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 are predefined templates a client exposes, typically triggered with something like a slash command plus a parameter. Resources and Prompts each get their own episode later in the series.
🧱 Scaffolding the server skeleton
The project starts as the plainest possible .NET project - a console app, nothing more:
dotnet new console -n MyFirstMcpServer
cd MyFirstMcpServer
dotnet add package ModelContextProtocol
dotnet add package Microsoft.Extensions.HostingProgram.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 - this 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’s also what 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. One line in Program.cs, worth doing 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 starts accepting 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
With the database in place, LibraryTools is marked [McpServerToolType] - the attribute WithToolsFromAssembly() is actually scanning for - and each callable method carries [McpServerTool] with a Description that’s 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();
// ...
}
}SearchBooks runs a fuzzy LIKE match across title and author. GetBooksByGenre runs an exact-match filter on one column. GetLibraryStats takes no parameters at all and returns an aggregate - total books, total copies, and a genre breakdown computed entirely inside SQL Server with GroupBy/Select/OrderByDescending. Three genuinely different query shapes, so the model has an actual 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, the video publishes 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 Claude a few different questions in one conversation shows it choosing correctly between three options 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 it returns line up exactly with what’s in the database.
🔍 Testing locally with MCP Inspector
Before wiring a server into an AI client at all, it’s worth verifying it independently with the MCP Inspector, run locally with one command:
npx @modelcontextprotocol/inspectorAdd the server manually (stdio transport, the path to the published .exe, and a working directory matching where appsettings.json actually lives), and the Inspector lists every tool, lets you fill in parameters by hand, and shows you the raw result - a much easier way to catch a problem than debugging it live inside an AI client.
Common pitfalls
Standard-output logging corrupts 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 exactly 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(...)]are the two attributes that make a class and its methods discoverable.Publishing as a self-contained single-file executable and pointing an AI client’s config directly at it is a clean way to hand off a finished local server.
Test independently with MCP Inspector before ever trusting an AI client to drive a tool for the first time.
Connect with me:
- Want to sponsor this newsletter? Let’s work together →
