Skip to main content

How to Build an MCP Server in C#

Short answer

An MCP server in C# is a console app that uses the official ModelContextProtocol NuGet package. You mark each tool method with [McpServerTool], describe it with [Description], and start the server over stdio with WithStdioServerTransport(). The bookmarks server below has two tools. It answers both the 2026-07-28 protocol revision and older apps that use the initialize handshake, and you connect it to Claude Desktop with a short JSON file.

Tested withMCP spec 2026-07-28C# SDK (ModelContextProtocol) 2.2.0.NET SDK 10.0.402MCP Inspector 2.11.0Node.js 24 (the Inspector needs 22.19 or later)Checked 20 Oct 2026

What you'll have at the end​

A bookmarks server with two tools, save_bookmark and search_bookmarks, that any MCP-compatible AI app can call. This is the result of calling save_bookmark from the Inspector command line in step 3:

{
"_meta": {
"io.modelcontextprotocol/serverInfo": {
"name": "bookmarks",
"version": "1.0.0.0"
}
},
"content": [
{
"type": "text",
"text": "Saved 'MCP docs'. Total bookmarks: 1"
}
]
}

The AI app starts your server as a child process and exchanges JSON-RPC messages with it over standard input and output. Your server never talks to the model directly. The app passes tool calls through.

Before you start

  • .NET 10 SDK. Install it from the .NET download page, then check it with dotnet --version. This guide was tested with SDK 10.0.402.
  • Basic comfort with a terminal.
  • For testing: Node.js 22.19 or later, which MCP Inspector needs.
  • To connect it at the end: Claude Desktop, or any other app from MCP clients.

1. Create the project​

dotnet new console -n bookmarks
cd bookmarks
dotnet add package ModelContextProtocol --version 2.2.0
dotnet add package Microsoft.Extensions.Hosting

ModelContextProtocol is the SDK's main package. It includes the hosting and dependency injection extensions and finds tools from attributes. Microsoft.Extensions.Hosting provides Host.CreateApplicationBuilder, which starts the server. The SDK's getting-started guide adds it the same way.

Your bookmarks.csproj should look like this. The version of Microsoft.Extensions.Hosting may be newer when you run the commands, which is fine. The MCP package stays at 2.2.0.

bookmarks.csproj
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.13" />
<PackageReference Include="ModelContextProtocol" Version="2.2.0" />
</ItemGroup>

</Project>

2. Write the server​

Replace the generated Program.cs with this file:

Program.cs
using System.ComponentModel;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ModelContextProtocol;
using ModelContextProtocol.Server;

var builder = Host.CreateApplicationBuilder(args);

// Logs go to stderr. Anything written to stdout corrupts the stdio transport.
builder.Logging.AddConsole(options =>
{
options.LogToStandardErrorThreshold = LogLevel.Trace;
});

var bookmarksFile = Environment.GetEnvironmentVariable("BOOKMARKS_FILE")
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".bookmarks-mcp.json");
builder.Services.AddSingleton(new BookmarkStore(bookmarksFile));

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

await builder.Build().RunAsync();

public sealed record Bookmark(string Url, string Title, string[] Tags);

public sealed class BookmarkStore(string path)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};

public async Task<List<Bookmark>> LoadAsync(CancellationToken cancellationToken)
{
if (!File.Exists(path))
{
return [];
}

await using var stream = File.OpenRead(path);
return await JsonSerializer.DeserializeAsync<List<Bookmark>>(stream, JsonOptions, cancellationToken) ?? [];
}

public async Task SaveAsync(List<Bookmark> bookmarks, CancellationToken cancellationToken)
{
await using var stream = File.Create(path);
await JsonSerializer.SerializeAsync(stream, bookmarks, JsonOptions, cancellationToken);
}
}

[McpServerToolType]
public sealed class BookmarkTools
{
[McpServerTool(Name = "save_bookmark"),
Description("Save a web page to the user's bookmarks. Use this when the user asks to save, bookmark or remember a link.")]
public static async Task<string> SaveBookmark(
BookmarkStore store,
ILogger<BookmarkTools> logger,
[Description("Full URL of the page, including https://")] string url,
[Description("Short human-readable title for the page")] string title,
// null! keeps the schema a plain optional array; a nullable type would emit ["array", "null"].
[Description("Optional lowercase tags, e.g. [\"recipes\", \"python\"]")] string[] tags = null!,
CancellationToken cancellationToken = default)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out _))
{
throw new McpException($"'{url}' is not a full URL. Include the scheme, such as https://");
}

var bookmarks = await store.LoadAsync(cancellationToken);
if (bookmarks.Any(b => b.Url == url))
{
return $"Already saved: {url}";
}

bookmarks.Add(new Bookmark(url, title, tags ?? []));
await store.SaveAsync(bookmarks, cancellationToken);
logger.LogInformation("Saved bookmark {Url}", url);
return $"Saved '{title}'. Total bookmarks: {bookmarks.Count}";
}

[McpServerTool(Name = "search_bookmarks"),
Description("Search saved bookmarks by words in the title, URL or tags.")]
public static async Task<string> SearchBookmarks(
BookmarkStore store,
[Description("One or more words to look for, case-insensitive")] string query,
CancellationToken cancellationToken = default)
{
var words = query.ToLowerInvariant().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
var bookmarks = await store.LoadAsync(cancellationToken);

var matches = bookmarks
.Where(b =>
{
var haystack = $"{b.Title} {b.Url} {string.Join(' ', b.Tags)}".ToLowerInvariant();
return words.All(word => haystack.Contains(word, StringComparison.Ordinal));
})
.ToList();

if (matches.Count == 0)
{
return $"No bookmarks match '{query}'.";
}

return string.Join("\n", matches.Select(b => $"- {b.Title}: {b.Url}"));
}
}

BookmarkTools is a class with two methods marked [McpServerTool]. The Name on each attribute is the tool name the client sees. The [Description] on a method is the tool description the model reads when it chooses a tool. A [Description] on a parameter becomes that parameter's description in the input schema, and the C# parameter types become JSON Schema types. So url is a required string, and tags is an optional array.

AddMcpServer() registers the server, WithStdioServerTransport() serves it over stdin and stdout, and WithTools<BookmarkTools>() registers the tools on that class. A few details matter when you change this file:

  • Logs go to stderr. LogToStandardErrorThreshold = LogLevel.Trace sends all host log output to stderr. Stdout carries protocol messages, so don't write debugging output there with Console.WriteLine. Use ILogger, as SaveBookmark does.
  • Optional tags use null!. The string[] tags = null! line makes tags an optional array in the schema. A nullable type such as string[]? makes the Inspector warn that the type is ["array", "null"], which it flags as less portable across clients. The code treats null as "no tags".
  • Errors reach the model. A method that throws McpException returns a tool result with isError set to true, and the message is included. The model can read the message and try again. SaveBookmark uses this for a URL without a scheme.
  • The file format is plain JSON. The store is BOOKMARKS_FILE if that environment variable is set, otherwise .bookmarks-mcp.json in your home folder. It uses the same url, title and tags keys as the Python and TypeScript versions.

Build and publish it:

dotnet publish -c Release -o publish

This writes bookmarks.dll into the publish folder. Run the publish command again after each code change, because the apps you connect launch that DLL.

3. Test it with MCP Inspector​

MCP Inspector connects to your server the same way an AI app does. Its command-line mode is the quickest check. List the tools with the current protocol revision:

npx @modelcontextprotocol/inspector@2.11.0 --cli dotnet publish/bookmarks.dll --protocol-era modern --method tools/list

You should see both tools, each with the parameter descriptions you wrote. This is trimmed from the real output:

{
"tools": [
{
"name": "save_bookmark",
"description": "Save a web page to the user's bookmarks. Use this when the user asks to save, bookmark or remember a link.",
"inputSchema": {
"type": "object",
"properties": {
"url": {
"description": "Full URL of the page, including https://",
"type": "string"
},
"title": {
"description": "Short human-readable title for the page",
"type": "string"
},
"tags": {
"description": "Optional lowercase tags, e.g. [\"recipes\", \"python\"]",
"type": "array",
"items": {
"type": "string"
},
"default": null
}
},
"required": [
"url",
"title"
]
}
},
...
]
}

Run the same command with --protocol-era legacy to check that older apps can connect too. Both runs should list save_bookmark and search_bookmarks.

Then call a tool:

npx @modelcontextprotocol/inspector@2.11.0 --cli dotnet publish/bookmarks.dll --protocol-era modern \
--method tools/call --tool-name save_bookmark \
--tool-arg url=https://modelcontextprotocol.io --tool-arg title="MCP docs"

Search for it:

npx @modelcontextprotocol/inspector@2.11.0 --cli dotnet publish/bookmarks.dll --protocol-era modern \
--method tools/call --tool-name search_bookmarks --tool-arg query=mcp

Pass a value without a scheme and the server returns a tool error. The Inspector exits with a non-zero status when a tool returns an error:

npx @modelcontextprotocol/inspector@2.11.0 --cli dotnet publish/bookmarks.dll --protocol-era modern \
--method tools/call --tool-name save_bookmark --tool-arg url=not-a-url --tool-arg title="Bad"
tip

Leave out --cli to open the Inspector's web interface, where you can browse tools and fill in parameters with a form. See Test with MCP Inspector.

4. Connect it to Claude Desktop​

Open Claude Desktop's config file in a text editor. Claude Desktop creates the file if it doesn't exist:

OSConfig file
macOS~/Library/Application Support/Claude/claude_desktop_config.json
Windows%APPDATA%\Claude\claude_desktop_config.json

Add your server under the mcpServers key. Use dotnet as the command, and pass the full path to bookmarks.dll in the publish folder. Run pwd in the project folder on macOS or Linux, or cd on Windows, to get the path.

claude_desktop_config.json
{
"mcpServers": {
"bookmarks": {
"command": "dotnet",
"args": ["/ABSOLUTE/PATH/TO/bookmarks/publish/bookmarks.dll"]
}
}
}

On Windows, double the backslashes in the path (C:\\Users\\you\\bookmarks\\publish\\bookmarks.dll) or use forward slashes. If Claude Desktop can't find dotnet, put its full path in command. Get it with which dotnet on macOS or Linux, or where dotnet on Windows.

Save the file, then completely quit Claude Desktop and reopen it so it loads the new configuration.

Ask Claude to "bookmark modelcontextprotocol.io as MCP docs, tagged mcp". Claude asks your permission before it calls save_bookmark, then confirms the save. In a new chat, ask "what have I bookmarked about mcp?" and it calls search_bookmarks.

Other apps keep their MCP config in different places. See MCP clients for the guide to each one.

Common problems​

The server doesn't appear in Claude Desktop. The config file usually has a JSON syntax error, such as a trailing comma or a missing brace. Check it, then completely quit and reopen the app. While you restart, follow the MCP logs. On macOS they're in ~/Library/Logs/Claude, and on Windows in %APPDATA%\Claude\logs. Files named mcp-server-<name>.log hold the server's stderr output.

tail -n 20 -f ~/Library/Logs/Claude/mcp*.log

The logs say the command can't be found. Desktop apps may not see the PATH from your terminal. Put the full path from which dotnet in command.

The server starts, then disconnects straight away. Something is writing to stdout. Look for Console.WriteLine calls, or for a library that prints to the console, and send the output through ILogger instead.

The Inspector warns that type is ["array", "null"]. A parameter is declared as a nullable type. Declare optional arrays as string[] tags = null!, as in step 2.

Your changes don't show up. The apps launch publish/bookmarks.dll, which only changes when you publish again. Run dotnet publish -c Release -o publish, then restart the app.

The model doesn't call your tool. The description doesn't match how people ask for the thing. Say plainly what the tool does and when to use it, as the save description does here. Tool names and descriptions covers this in depth.

For more errors and fixes, see Fix MCP connection errors.

FAQ​

Is this the official C# SDK?

Yes. The ModelContextProtocol package comes from the official MCP C# SDK repository, which is maintained in collaboration with Microsoft. This guide uses version 2.2.0.

Which protocol versions does the C# SDK support?

Version 2.2.0 supports the 2026-07-28 revision and the older 2025-11-25, 2025-06-18, 2025-03-26 and 2024-11-05 revisions. This guide tested the modern and legacy protocol eras with MCP Inspector.

Do I have to use .NET 10?

This guide targets .NET 10 and was tested with the .NET 10 SDK. The NuGet package also lists .NET 8 and .NET 9 targets, but this guide did not test them.

Do I need to host the server online?

No. A server that runs over stdio, like the one in this guide, runs on your own computer, and the AI app starts it for you. You only need hosting when clients on other machines or web-based apps have to reach it. See Local vs remote MCP servers.

Should I build this in Python or TypeScript instead?

Pick the language your team already uses. The protocol is the same in every language, so any MCP-compatible app can connect to a server written in any of them. The same bookmarks server is in the Python and TypeScript guide.