Elton Cassas

Build a Local MCP Server in C#

Learn how to build a local MCP server in C# with stdio and SQLite, and how to check the tool contract.

9 min read

c#

.net core

ef core

unit testing

Portrait of Elton Cassas

Elton Cassas

Support
On this page
Support

Build a Local MCP Server in C#

Table of Content

What is Bookshelf

Bookshelf is a local MCP server. It helps to keep a reading list: shelves, books and notes.

  • The MCP client is the front end. For example Claude or GitHub Copilot in VS Code.
  • There is no website and no REST API.
  • The books stay on the machine, in a SQLite file.
  • It uses .NET 10, the ModelContextProtocol C# SDK and EF Core with SQLite.

The server is built in pieces. Each piece builds, runs and has tests before the next one starts:

  • A project skeleton with the layers.
  • SQLite persistence.
  • The stdio host with the read tools.
  • The write tools and the private notes.

Why local and stdio

A hosted service needs a database server, users and authentication. A local server needs none of them.

  • The client starts the server as a child process.
  • They talk through stdin and stdout. This is the stdio transport.
  • There is no network endpoint, so there is nothing to protect with OAuth.

The MCP client talks to the server process over stdin and stdout. Inside it the transport calls the tools, the handlers and EF Core, which use a SQLite file. Logs go to stderr or a file.

stdout belongs to the protocol. Any other text written there breaks the connection. Send the logs to stderr or to a file.

var builder = Host.CreateApplicationBuilder(args);

builder.Logging.ClearProviders();
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);

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

await builder.Build().RunAsync();

The server also writes a daily log file next to the database. It does not log the text of the books and notes. It logs the tool name, the argument names, the numbers and the duration.

Project layout

  • Models: entities, DTOs and enums. It depends on nothing.
  • Application: handlers, validators and the repository interfaces. It does not use EF Core.
  • Persistence: the EF Core SQLite code.
  • Mcp: the executable with the tools.

Architecture tests keep the layers apart. They use NetArchTest and fail if a lower layer references a higher one.

SQLite with EF Core

  • The database file is in LocalApplicationData/Bookshelf/bookshelf.db.
  • The environment variable BOOKSHELF_DB_PATH changes the location.
  • The server creates the folder and runs the migrations when it starts.
  • EF Core takes a lock before it runs the migrations, so two server processes can start at the same time.
  • On SQLite the lock is a row in the __EFMigrationsLock table. If the process is killed during a migration, the row can stay and the next start waits for it. Delete the row to fix it.

SQLite allows one writer at a time, even in WAL mode. Set WAL and a busy timeout on every connection. A short wait then retries instead of failing.

internal sealed class WalAndBusyTimeoutInterceptor : DbConnectionInterceptor
{
    private const string Pragmas =
        "PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000; PRAGMA synchronous=NORMAL;";

    public override void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData)
    {
        using var command = connection.CreateCommand();
        command.CommandText = Pragmas;
        command.ExecuteNonQuery();
    }
}

Add it to the context options:

options
    .UseSqlite($"Data Source={path}")
    .AddInterceptors(new WalAndBusyTimeoutInterceptor());

Other schema decisions:

  • Enums are stored as strings.
  • Shelf names use the NOCASE collation, so History and history are the same name.
  • Dates are UTC DateTime, not DateTimeOffset. The SQLite provider does not translate comparison and ordering of DateTimeOffset to SQL, so they run on the client. The EF Core docs recommend UTC DateTime.

One test opened the raw database connection instead of the EF connection. The interceptor did not run, so the busy timeout was 0. The test passed on Windows only because of a pooled connection from the migration. It failed on Linux in CI. The fix is to open the connection through EF in the test.

The tools

  • Read: list shelves, list books and get a book.
  • Write: create, update and delete for shelves, books and notes.
  • Private: get a private note.

A tool is a static method with attributes. The application handlers arrive as method parameters. The SDK creates a DI scope for each request, so scoped services work.

[McpServerToolType]
public static class BookTools
{
    [McpServerTool(Name = "bookshelf_get_book", Title = "Get a book", ReadOnly = true, OpenWorld = false)]
    [Description("Gets a book with its status and public notes, without private notes.")]
    public static async Task<CallToolResult> GetBook(
        IBookHandler bookHandler,
        [Description("The id of the book.")][Range(1, int.MaxValue)] int id,
        CancellationToken cancellationToken)
    {
        var book = ToolResults.Unwrap(
            await bookHandler.RetrieveBook(id, cancellationToken),
            ToolResults.BookNotFound(id));

        return ToolOutput.TextAndStructured(
            BookTextFormatter.Book(book), ResultMapper.BookToView(book));
    }
}

About the notes:

  • A note is public or private. A private note is saved, but get_book never returns it.
  • create_book takes its notes inline. It validates all of them before it saves anything.

Errors

  • Throw McpException for an error the model should read. The SDK returns a result with isError set and shows the message.
  • Any other exception gives only a generic message.
  • Say what to do next in the message. For example: “Book 999 was not found. Call bookshelf_list_books to see the valid book ids.”

Hide the private notes

The model must not see a private note unless the person asks for it.

  • get_book returns BookView. It has no private note property, so it cannot leak.
  • get_private_note is a separate tool. The client can ask the person to approve it before it runs.
  • The model reads a private note only after that.
public record BookView(
    int Id,
    int ShelfId,
    string Title,
    string Author,
    ReadingStatus Status,
    IReadOnlyList<string> Notes);

public enum ReadingStatus
{
    NotStarted,
    Reading,
    Finished,
}

Avoid a nullable flag such as bool? IsFinished, where null means “not started”. An enum says the same in its name, so no comment is needed.

Check the tool contract

A review with the mcp-builder skill found problems. A real tools/list call over stdio confirmed them.

Annotations

  • Unset hints are not sent. Clients then assume the spec defaults: destructive and open world.
  • So the create tools looked destructive, and every tool looked like it talks to an external service.
  • Set every hint and a Title on every tool.

The policy:

  • Read tools: ReadOnly.
  • Create and add: Destructive = false, Idempotent = false.
  • Update: Destructive = true, Idempotent = true, because it overwrites existing values.
  • Delete: Destructive = true, Idempotent = true.
  • All tools: OpenWorld = false, because they only use the local database.
[McpServerTool(
    Name = "bookshelf_delete_book",
    Title = "Delete a book",
    Destructive = true,
    Idempotent = true,
    OpenWorld = false)]

Names

Use a service prefix. The server runs next to other servers, and names like get_book can clash. All the tools start with bookshelf_. Keep the names in one class of constants.

Structured output

A tool can return text and typed structured content together. The text is for people and models. The structured content is for programs, for example a UI.

  • Return a CallToolResult with both.
  • The SDK cannot infer an output schema from a CallToolResult. Declare the result type with an attribute and add the schema in the list tools filter.
  • Newer versions of the SDK can do this without the filter, with UseStructuredContent and OutputSchemaType on McpServerTool. Check the version in use. An old version ignores them, and the tool then has no output schema.
foreach (var tool in result.Tools)
{
    if (OutputSchemas.TryGet(tool.Name, out var outputSchema))
    {
        tool.OutputSchema = outputSchema;
    }
}

Input schema

  • DataAnnotations on the parameters become limits in the schema: StringLength, MinLength, MaxLength and Range. The validators on the server stay the authority.
  • Depending on the SDK version, nested required fields and array items can be advertised as nullable. A client then accepts a missing value, and the server returns an error after the call. Check the schema from tools/list. Remove the null from the required ones, so clients can validate before they call.
  • An optional parameter needs a default. Without = null, the SDK treats it as required, and a call that leaves it out fails with a generic error.
[Description("Optional note about the book.")]
[MaxLength(2000)] string? note = null,

Pin it with a test

One test class pins the whole contract: names, annotations, required inputs, limits and schemas. It was checked by removing one annotation on purpose. The test failed and named the tool.

Test with a real client

The integration tests start the real server as a child process over stdio. They use a seeded temp database and the SDK client.

var transport = new StdioClientTransport(new StdioClientTransportOptions
{
    Command = "dotnet",
    Arguments = [Path.Combine(AppContext.BaseDirectory, "Bookshelf.Mcp.dll")],
    EnvironmentVariables = new Dictionary<string, string?>
    {
        ["BOOKSHELF_DB_PATH"] = databaseFile,
    },
});

await using var client = await McpClient.CreateAsync(transport);
var result = await client.CallToolAsync(
    "bookshelf_get_book", new Dictionary<string, object?> { ["id"] = 1 });

A flaky handshake

  • By default, the SDK client first sends a server/discover probe and waits 5 seconds. Then it falls back to initialize.
  • On a slow Windows CI runner, one test failed in two different runs with: Method 'initialize' is not available on protocol version '2026-07-28'.
  • It did not happen on a local machine.
  • The fix in the tests is to ask for the initialize handshake directly. Three CI runs in a row were green after that.
var client = await McpClient.CreateAsync(
    transport,
    new McpClientOptions { ProtocolVersion = "2025-11-25" });

The server also migrates the database in the background. It starts to answer the handshake at once, and the tool calls wait until the database is ready.

CI and formatting

The pipeline runs the build and the tests on Ubuntu and Windows. It also runs dotnet format --verify-no-changes.

  • .editorconfig asks for CRLF line endings. Without a .gitattributes file, a Linux checkout has LF, and the format check fails on every file.
  • The formatter and StyleCop disagree on a few constructs, and the check then never settles. Avoid new (), string[]?, x[0]! and collection expressions with brackets.
  • StyleCop rule SA1313 asks for parameter names that start with a lower-case letter. The docs say positional records are exempt, but some versions of the analyzer still report them. The warning then fails the build when warnings are errors. If it happens, turn the rule off for the folder with the result records.

Comments and clean code

Comment only when it is absolutely necessary. Names do the work.

  • Rename the variable, the method or the class first.
  • Extract a method with a clear name.
  • Use an enum, or a type, instead of a nullable flag that needs an explanation.
  • Keep a comment for a reason the code cannot say, such as a quirk in an SDK.

For example, a comment said “register this service before the MCP server”. The migration now starts lazily, so the order does not matter and the comment is gone.

Tools that helped

  • The dotnet new mcpserver template. It adds PackAsTool, PackageType=McpServer and a .mcp/server.json file. A user runs the package with dnx.
  • The template has options for transport, native AOT, self-contained publish and framework.
  • Native AOT does not fit this server. EF Core, Mapster, FluentValidation and WithToolsFromAssembly use reflection.
  • Microsoft Learn and Context7, to check the SDK docs instead of the memory of the model.
  • The mcp-builder skill, to review the tools. Its guides cover Python and TypeScript, so the C# details came from the Microsoft docs.

Resources

GitHub - modelcontextprotocol/csharp-sdk: The official C# SDK for Model Context Protocol servers and clients. Maintained in collaboration with Microsoft.The official C# SDK for Model Context Protocol servers and clients. Maintained in collaboration with Microsoft. - modelcontextprotocol/csharp-sdkgithub.com
Quickstart - Create a minimal MCP server and publish to NuGet - .NETLearn to create and connect to a minimal MCP server using C# and publish it to NuGet.learn.microsoft.com
MCP servers in NuGet packagesHow can MCP servers be distributed using NuGet?learn.microsoft.com
SQLite Database Provider - Limitations - EF CoreLimitations of the Entity Framework Core SQLite database provider as compared to other providerslearn.microsoft.com
What is the Model Context Protocol (MCP)? - Model Context Protocolmodelcontextprotocol.io
GitHub - anthropics/skills: Public repository for Agent SkillsPublic repository for Agent Skills. Contribute to anthropics/skills development by creating an account on GitHub.github.com

Related posts

Deploy Azure Functions with GitHub Actions

Learn how to deploy a .NET function app to Azure with GitHub Actions and OIDC, without passwords.

November 9, 2026

devops

azure

continuous integration

A gray tile with an arrow pointing to a gray cloud

Subscribe to my biweekly newsletter

As a Fullstack developer, I know how important it is to stay updated with multiple technologies. If you are interested about web design for frontend development, backend development using C#, Azure cloud provider or architecture guidelines, subscribe to my newsletter and never miss a beat.

or

Subscribe On Substack

Subscribe to my biweekly newsletter

New posts on web design, C#, Azure and architecture, every two weeks. Unsubscribe at any time.

or

Subscribe On Substack