Build MCP servers

How to build an MCP server in C#

A notes server in C# with the official SDK, running over stdio and Streamable HTTP, checked with the SDK's own client and set up for Claude Code.

By
Dan Flanagan
Updated
October 14, 2026
Tested with
.NET SDK 10.0.402 (net10.0) and ModelContextProtocol / ModelContextProtocol.AspNetCore 2.2.0 on macOS 26.6.2. Claude Code step not re-run.
Reading time
5 min

New to MCP? What is an MCP server? covers the basics first.

The C# SDK is three NuGet packages and a handful of attributes: mark a method [McpServerTool] and the schema comes from its parameters. The notes server below has three tools. list_notes and read_note only read, and add_note writes a file, which matters later when you decide which agents get which tools.

Every command below ran on the versions in the header. ModelContextProtocol.Core holds the client and low-level protocol types. ModelContextProtocol adds the hosting and dependency-injection builder most servers use, and ModelContextProtocol.AspNetCore adds the Streamable HTTP transport on top of that. Pin exact versions. 2.2.0 is current here.

Prerequisites

  • .NET SDK 10.0 or newer. I used 10.0.402, unpacked from Microsoft's release tarball into a working directory rather than installed system-wide.
  • Claude Code, if you want to try the server there in step 5.

Step 1: Create the project

A solution with four projects shares the tool definitions between both hosts and the test client: NotesTools is a class library with the tools, Notes.Stdio and Notes.Http are the two ways to run them, and Notes.Client checks both.

bash
dotnet new sln -n notes-server
dotnet new classlib -n NotesTools -o NotesTools --framework net10.0
dotnet new console -n Notes.Stdio -o Notes.Stdio --framework net10.0
dotnet new web -n Notes.Http -o Notes.Http --framework net10.0
dotnet new console -n Notes.Client -o Notes.Client --framework net10.0
dotnet sln add NotesTools Notes.Stdio Notes.Http Notes.Client
text
The template "Solution File" was created successfully.
The template "Class Library" was created successfully.
The template "Console App" was created successfully.
The template "ASP.NET Core Empty" was created successfully.
The template "Console App" was created successfully.
Project `NotesTools/NotesTools.csproj` added to the solution.
Project `Notes.Stdio/Notes.Stdio.csproj` added to the solution.
Project `Notes.Http/Notes.Http.csproj` added to the solution.
Project `Notes.Client/Notes.Client.csproj` added to the solution.

Now add the SDK packages and wire the projects together:

bash
dotnet add NotesTools package ModelContextProtocol --version 2.2.0
dotnet add Notes.Stdio package Microsoft.Extensions.Hosting --version 10.0.13
dotnet add Notes.Stdio reference NotesTools
dotnet add Notes.Http package ModelContextProtocol.AspNetCore --version 2.2.0
dotnet add Notes.Http reference NotesTools
dotnet add Notes.Client package ModelContextProtocol.Core --version 2.2.0
text
info : PackageReference for package 'ModelContextProtocol' version '2.2.0' added to file 'NotesTools/NotesTools.csproj'.
info : PackageReference for package 'Microsoft.Extensions.Hosting' version '10.0.13' added to file 'Notes.Stdio/Notes.Stdio.csproj'.
Reference `..\NotesTools\NotesTools.csproj` added to the project.
info : PackageReference for package 'ModelContextProtocol.AspNetCore' version '2.2.0' added to file 'Notes.Http/Notes.Http.csproj'.
Reference `..\NotesTools\NotesTools.csproj` added to the project.
info : PackageReference for package 'ModelContextProtocol.Core' version '2.2.0' added to file 'Notes.Client/Notes.Client.csproj'.

Notes.Client only needs ModelContextProtocol.Core, because a client never touches the hosting builder in ModelContextProtocol.

Step 2: Mark the tools with attributes

Put the tools in NotesTools/Notes.cs, where both hosts can reference them:

csharp
using System.ComponentModel;
using System.Text.RegularExpressions;
using ModelContextProtocol;
using ModelContextProtocol.Server;

namespace NotesTools;

/// <summary>Maps a note title to a path inside NOTES_DIR, and refuses anything that would escape it.</summary>
internal static partial class NotePaths
{
    [GeneratedRegex("[^a-z0-9-]+")]
    private static partial Regex NotSlugChars();

    public static string NotesDir { get; } = Path.GetFullPath(
        Environment.GetEnvironmentVariable("NOTES_DIR") ?? "notes");

    public static string Resolve(string title)
    {
        var slug = NotSlugChars().Replace(title.ToLowerInvariant(), "-").Trim('-');
        if (slug.Length == 0)
        {
            throw new McpException("A note needs a title with at least one letter or number.");
        }

        return Path.Combine(NotesDir, 
quot;{slug}.md"); } } [McpServerToolType] public sealed class NotesServerTools { [McpServerTool(Name = "list_notes", ReadOnly = true), Description("List the titles of every saved note.")] public static string[] ListNotes() { Directory.CreateDirectory(NotePaths.NotesDir); return Directory.EnumerateFiles(NotePaths.NotesDir, "*.md") .Select(path => Path.GetFileNameWithoutExtension(path)) .OrderBy(title => title, StringComparer.Ordinal) .ToArray(); } [McpServerTool(Name = "read_note", ReadOnly = true), Description("Return the text of one note.")] public static string ReadNote([Description("The title of the note to read.")] string title) { var path = NotePaths.Resolve(title); if (!File.Exists(path)) { throw new McpException(
quot;No note called \"{title}\"."); } return File.ReadAllText(path); } [McpServerTool(Name = "add_note", ReadOnly = false, Destructive = false), Description("Save a note. Fails if a note with that title already exists.")] public static string AddNote( [Description("The title of the note.")] string title, [Description("The text to save.")] string body) { var path = NotePaths.Resolve(title); if (File.Exists(path)) { throw new McpException(
quot;A note called \"{title}\" already exists."); } Directory.CreateDirectory(NotePaths.NotesDir); File.WriteAllText(path, body); return
quot;Saved {Path.GetFileNameWithoutExtension(path)}."; } }

Then Notes.Stdio/Program.cs:

csharp
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using NotesTools;

var builder = Host.CreateApplicationBuilder(args);

// Standard output carries the protocol on stdio, so send logs to standard error.
builder.Logging.AddConsole(options => options.LogToStandardErrorThreshold = LogLevel.Trace);

builder.Services.AddMcpServer()
    .WithStdioServerTransport()
    .WithTools<NotesServerTools>();

await builder.Build().RunAsync();

The tool class can't be static here. WithTools<T>() takes the class as a type argument, and C# rejects a static class there with CS0718. So NotesServerTools is a sealed class with static methods. The SDK's getting-started docs use a static class with WithToolsFromAssembly(), which finds tool types by reflection and doesn't have this limit.

Attributes are the schema. [McpServerTool] marks the method, [Description] on the method and on each parameter becomes the tool and argument descriptions the model reads, and the SDK infers the JSON Schema from the parameter types by reflection. There's no schema object to keep in sync by hand.

Titles never reach the filesystem as typed. NotePaths.Resolve turns every title into a slug, so ../../etc/passwd becomes etc-passwd.md inside the notes folder. Treat tool arguments like form input. A model's arguments can trace back to a document it just read.

Throw McpException for failures you expect. The SDK catches it and returns a result with IsError set to true and the text An error occurred invoking '{tool}': {message}. Any other exception gets the same result without your message, just An error occurred invoking '{tool}'., so a bug doesn't leak internals to the model.

Annotations are hints. ReadOnly = true tells a client the tool changes nothing, which it can use when deciding what to confirm with you. The SDK does not enforce it, so a tool marked read-only can still write files if its code does.

Step 3: Test it with a client

Write a client against the stdio host in Notes.Client/Program.cs:

csharp
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;

IClientTransport transport = args is ["http"]
    ? new HttpClientTransport(new HttpClientTransportOptions
    {
        Endpoint = new Uri("http://127.0.0.1:5080/mcp"),
    })
    : new StdioClientTransport(new StdioClientTransportOptions
    {
        Command = "dotnet",
        Arguments = ["run", "--project", "Notes.Stdio", "--no-build"],
    });

await using var client = await McpClient.CreateAsync(transport);

var tools = await client.ListToolsAsync();
Console.WriteLine(string.Join(", ", tools.Select(t => t.Name)));

McpClientTool Find(string name) => tools.First(t => t.Name == name);

await Find("add_note").CallAsync(new Dictionary<string, object?>
{
    ["title"] = "Release checklist",
    ["body"] = "Tag, build, announce.",
});

var listed = await Find("list_notes").CallAsync(new Dictionary<string, object?>());
Console.WriteLine(((TextContentBlock)listed.Content[0]).Text);

var note = await Find("read_note").CallAsync(new Dictionary<string, object?> { ["title"] = "release checklist" });
Console.WriteLine(((TextContentBlock)note.Content[0]).Text);

var dup = await Find("add_note").CallAsync(new Dictionary<string, object?>
{
    ["title"] = "Release checklist",
    ["body"] = "again",
});
Console.WriteLine(
quot;{dup.IsError} {((TextContentBlock)dup.Content[0]).Text}"); var odd = await Find("read_note").CallAsync(new Dictionary<string, object?> { ["title"] = "../../etc/passwd" }); Console.WriteLine(
quot;{odd.IsError} {((TextContentBlock)odd.Content[0]).Text}");

Build it, and run the client from the solution folder so the relative Notes.Stdio path resolves:

bash
dotnet build
dotnet run --project Notes.Client --no-build
text
list_notes, read_note, add_note
["release-checklist"]
Tag, build, announce.
True An error occurred invoking 'add_note': A note called "Release checklist" already exists.
True An error occurred invoking 'read_note': No note called "../../etc/passwd".

ListToolsAsync came back in declaration order. Don't assume any order across SDKs. ListNotes returns a plain string[], and since the tool doesn't opt into structured content, that serializes to the bare JSON array text above rather than a wrapped object. The client started Notes.Stdio as a subprocess over its stdin and stdout, the same way Claude Code will.

Step 4: Serve it over Streamable HTTP with ASP.NET Core

Notes.Http/Program.cs hosts the same tools on the network instead of stdio:

csharp
using ModelContextProtocol.AspNetCore;
using NotesTools;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddMcpServer()
    .WithHttpTransport()
    .WithTools<NotesServerTools>();

var app = builder.Build();

app.MapMcp("/mcp");
app.Run("http://127.0.0.1:5080");
bash
dotnet run --project Notes.Http --no-build

The address is in app.Run because dotnet run applies the template's Properties/launchSettings.json, which sets its own URL and overrides ASPNETCORE_URLS. Port 5000 is taken by AirPlay Receiver on macOS, so this uses 5080.

A raw initialize call shows the protocol version it negotiates. It also shows that this is a stateless deployment: no Mcp-Session-Id header comes back.

bash
curl -s -X POST http://127.0.0.1:5080/mcp \
  -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
text
event: message
data: {"result":{"protocolVersion":"2025-11-25","capabilities":{"logging":{},"tools":{}},"serverInfo":{"name":"Notes.Http","version":"1.0.0.0"}},"id":1,"jsonrpc":"2.0"}

tools/list returns the three tools with the flat annotation booleans set above:

text
{"name":"list_notes","annotations":{"readOnlyHint":true}}
{"name":"read_note","annotations":{"readOnlyHint":true}}
{"name":"add_note","annotations":{"destructiveHint":false,"readOnlyHint":false}}

Unlike the Go and TypeScript SDKs, which reject a mismatched Host header by default, MapMcp() here does not validate it on its own. A forged header against the running server above still got a plain 200:

bash
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: evil.example" \
  -X POST http://127.0.0.1:5080/mcp -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
text
200

That matches the SDK's transport docs: Kestrel does not validate Host headers by default, so set AllowedHosts to the real host names yourself rather than relying on the transport. Do that before this leaves your machine.

Calling the tools with tools/call over HTTP gives these data: lines. The SDK escapes quotes as \u0027 and \u0022:

text
{"result":{"content":[{"type":"text","text":"Saved release-checklist."}]},"id":3,"jsonrpc":"2.0"}
{"result":{"content":[{"type":"text","text":"An error occurred invoking \u0027add_note\u0027: A note called \u0022Release checklist\u0022 already exists."}],"isError":true},"id":4,"jsonrpc":"2.0"}
{"result":{"content":[{"type":"text","text":"An error occurred invoking \u0027read_note\u0027: No note called \u0022../../etc/passwd\u0022."}],"isError":true},"id":5,"jsonrpc":"2.0"}

A tool name the server doesn't have is a different kind of failure. It never reaches a tool handler, so it comes back as a JSON-RPC protocol error instead of a result with isError:

text
{"error":{"code":-32602,"message":"Unknown tool: \u0027nonexistent_tool\u0027"},"id":6,"jsonrpc":"2.0"}

Pointing Notes.Client at the HTTP endpoint instead of spawning the stdio process gives the same output as step 3:

bash
dotnet run --project Notes.Client --no-build -- http
text
list_notes, read_note, add_note
["release-checklist"]
Tag, build, announce.
True An error occurred invoking 'add_note': A note called "Release checklist" already exists.
True An error occurred invoking 'read_note': No note called "../../etc/passwd".

The server binds to 127.0.0.1, so only your machine can reach it. Put authentication in front of it before it goes anywhere public, or it will run its tools for anyone who finds the URL.

Step 5: Add it to Claude Code

Publish the stdio host and register it for this project:

bash
dotnet publish Notes.Stdio -c Release -o publish/stdio
claude mcp add --scope project notes -- "$PWD/publish/stdio/Notes.Stdio"

That writes a .mcp.json file in the folder:

json
{
  "mcpServers": {
    "notes": {
      "type": "stdio",
      "command": "/path/to/notes-server/publish/stdio/Notes.Stdio",
      "args": [],
      "env": {}
    }
  }
}

claude mcp get notes shows it as pending approval. Claude Code asks before running a project server, because .mcp.json gets committed and cloning a repository shouldn't run a binary on your machine. Start claude in the folder, approve it, and ask it to save a note.

To use the HTTP version instead, with the server from step 4 running:

bash
claude mcp add --scope project --transport http notes http://127.0.0.1:5080/mcp

Claude Code reads its servers when a session starts, so open a new session after adding one.

Step 6: Put it behind Statio

Any client that connects to this server gets all three tools. Statio is a gateway that grants them one at a time, so add_note can go to the one agent that writes while the rest get list_notes and read_note.

The setup is in Connect a client and Permissions, and MCP permissions compares the other ways to limit an agent's tools.

Conclusion

You now have a C# MCP server running over stdio or Streamable HTTP and set up for Claude Code. The attributes do most of the work: [McpServerTool] and [Description] become the tool list the model reads, and a thrown McpException becomes an error it can read. They don't check the Host header. MapMcp() leaves that to Kestrel's AllowedHosts, so set it before the server leaves your machine.

Where to go next: