Building an MCP Server in C# and .NET
A working MCP server in C# with the official SDK: two tools with dependency injection, served over stdio and over HTTP with ASP.NET Core, tested in the MCP Inspector and connected to Claude Code and VS Code. Plus the dotnet new template, and what the SDK does with your errors.
8 min read
To build an MCP server in C#, add the official SDK package ModelContextProtocol and Microsoft.Extensions.Hosting to a console app, call AddMcpServer().WithStdioServerTransport().WithTools<T>(), and mark methods with [McpServerTool] and [Description]: the method signature becomes the input schema. For a remote server, use ModelContextProtocol.AspNetCore in a web app, swap in WithHttpTransport() and call app.MapMcp(). Microsoft also ships a project template, dotnet new mcpserver. The code below was built and run with the .NET 10 SDK (10.0.400) and version 2.2.0 of the SDK, the current release on NuGet.
This is the .NET version of the build. How to design tools that assistants choose and call well is in how to build an MCP server; the same server in Python is in MCP server in Python.
The packages
The SDK is maintained in the Model Context Protocol organisation on GitHub, and its getting started guide (opens in a new tab) splits it into three NuGet packages. Pick one:
ModelContextProtocol: hosting, dependency injection and attribute-based discovery of tools, prompts and resources. The right start for a stdio server or a client.ModelContextProtocol.AspNetCore: everything above plus the Streamable HTTP transport, for a server hosted in ASP.NET Core.ModelContextProtocol.Core: the client and low-level server APIs with the fewest dependencies.
Two extension packages, ModelContextProtocol.Extensions.Apps and ModelContextProtocol.Extensions.Tasks, add MCP Apps and long-running tasks. Version 2 supports the stateless 2026-07-28 revision of the protocol and older clients that still start with initialize, on the same server.
Or start from the template
Microsoft’s MCP server quickstart (opens in a new tab) uses a project template, Microsoft.McpServer.ProjectTemplates, which it marks as preview and which needs the .NET 10 SDK to install.
dotnet new install Microsoft.McpServer.ProjectTemplates dotnet new mcpserver -n SampleMcpServer dotnet new mcpserver --help # transport (local stdio or remote http), AOT, self-contained
It generates Program.cs, a sample get_random_number tool and a server.json that describes the package for publishing to NuGet. By default it builds a self-contained tool package for the common platforms. The steps below build the same thing by hand, so you can see each piece.
1. A stdio server with two tools
dotnet new console -n TasksMcp && cd TasksMcp dotnet add package ModelContextProtocol dotnet add package Microsoft.Extensions.Hosting
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
var builder = Host.CreateApplicationBuilder(args);
// stdout is the protocol channel: send every log line to stderr.
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
builder.Services.AddSingleton<TaskStore>();
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithTools<TaskTools>();
await builder.Build().RunAsync();using System.ComponentModel;
using ModelContextProtocol;
using ModelContextProtocol.Server;
[McpServerToolType]
public sealed class TaskTools(TaskStore store)
{
[McpServerTool(Name = "tasks_list", ReadOnly = true, OpenWorld = false)]
[Description("List tasks by id. Call this before adding one, to avoid duplicates.")]
public IReadOnlyDictionary<string, string> List(
[Description("True to leave out finished tasks.")] bool openOnly = true)
=> store.List(openOnly);
[McpServerTool(Name = "tasks_add", Destructive = false, OpenWorld = false)]
[Description("Add a task and return its id, e.g. T-2.")]
public string Add(
[Description("What needs doing, in a few words.")] string title)
{
if (store.Exists(title))
throw new McpException($"A task called '{title}' already exists. Call tasks_list to find its id.");
return $"Added {store.Add(title)}: {title}";
}
}
// Stands in for your product's API or database.
public sealed class TaskStore
{
private readonly Dictionary<string, (string Title, bool Done)> _tasks = new()
{
["T-1"] = ("Write release notes", false),
};
public IReadOnlyDictionary<string, string> List(bool openOnly) =>
_tasks.Where(t => !(openOnly && t.Value.Done))
.ToDictionary(t => t.Key, t => t.Value.Title);
public bool Exists(string title) =>
_tasks.Values.Any(t => t.Title.Equals(title, StringComparison.OrdinalIgnoreCase));
public string Add(string title)
{
var id = $"T-{_tasks.Count + 1}";
_tasks[id] = (title, false);
return id;
}
}What each part turns into on the wire, checked against the server’s own tools/list output:
- Dependency injection works as it does anywhere in .NET.
TaskStoreis a singleton, and the SDK createsTaskToolswith it. A tool method can also takeMcpServer,CancellationToken,IProgress<ProgressNotificationValue>,ClaimsPrincipalor any registered service as a parameter; those never appear in the schema. - Without
Name, the tool is named after the method in snake case: a method calledListOpenTasksbecomeslist_open_tasks. Set it explicitly when you want a product prefix. [Description]on the method becomes the tool’s description; on a parameter, that property’s description. A parameter with a default value is optional and carries"default": truein the schema.ReadOnly,Destructive,IdempotentandOpenWorldon the attribute become thereadOnlyHint,destructiveHint,idempotentHintandopenWorldHintannotations. Writing MCP tool descriptions covers when to set each.- Errors: an
McpExceptionbecomes a result withisError: true, its message prefixed with “An error occurred invoking 'tasks_add':”. Any other exception reaches the model as that generic sentence only, so as not to leak internals.McpProtocolExceptionbecomes a JSON-RPC error instead.
More from the same attributes
- Discovery:
WithTools<TaskTools>()registers one class.WithToolsFromAssembly()finds every class marked[McpServerToolType]in the assembly instead. - Prompts and resources follow the same pattern, with
[McpServerPromptType]and[McpServerPrompt], and[McpServerResourceType]and[McpServerResource]. - Structured results: set
UseStructuredContent = trueon[McpServerTool]and the SDK publishes an output schema for the return type and puts the value instructuredContent. - Richer content: return an
ImageContentBlock, anAudioContentBlock, anEmbeddedResourceBlockor a list of content blocks instead of a string. - Headers for routing:
[McpHeader("Region")]on a simple parameter asks HTTP clients to copy its value into anMcp-Param-Regionheader, so a gateway can route on it without reading the body. Keep secrets out of it; headers are visible to every proxy on the way.
2. The same tools over HTTP
Create a web project, add ModelContextProtocol.AspNetCore, copy TaskTools.cs in, and replace Program.cs:
using ModelContextProtocol.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<TaskStore>();
builder.Services
.AddMcpServer()
.WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless)
.WithTools<TaskTools>();
var app = builder.Build();
app.MapMcp("/mcp");
app.Run("http://localhost:3001");Stateless is now the SDK’s default and its recommendation; setting it explicitly means a future default cannot change your server’s behaviour. The SDK’s guide to stateless and stateful mode (opens in a new tab) explains the other two settings: Stateful, for servers that need sessions and which refuses 2026-07-28 clients so they fall back, and StatefulForInitializeClients, which serves both. Run stateless, this server answered a 2026-07-28 server/discover and a legacy initialize at the same address.
Kestrel does not check the Host header by default, which leaves a local server open to DNS rebinding. Set AllowedHosts in appsettings.json to "localhost;127.0.0.1" rather than the template’s "*"; with that in place, a request with a foreign Host got a 400. Before this goes beyond your machine it needs sign-in: see how to add authentication to an MCP server. The difference between the two transports is in MCP transports.
3. Test it in the MCP Inspector
dotnet build npx @modelcontextprotocol/inspector --cli dotnet bin/Debug/net10.0/TasksMcp.dll --method tools/list npx @modelcontextprotocol/inspector --cli dotnet bin/Debug/net10.0/TasksMcp.dll \ --method tools/call --tool-name tasks_add --tool-arg "title=Write release notes" # the HTTP server, while it is running npx @modelcontextprotocol/inspector --cli --server-url http://localhost:3001/mcp --transport http --method tools/list
The second call adds a duplicate, so it comes back with isError: true and the sentence about tasks_list, and the Inspector exits with code 5. Point it at the built DLL rather than dotnet run --project: the Inspector’s command line took --project as its own option, and dotnet run could not find the project. Without --cli the same commands open the web interface. More in the MCP Inspector.
4. Connect it to Claude Code and VS Code
# stdio: Claude Code starts the server itself (use an absolute path) claude mcp add --transport stdio tasks -- dotnet run --project /absolute/path/TasksMcp/TasksMcp.csproj # or the HTTP server, while it is running claude mcp add --transport http tasks http://localhost:3001/mcp
Everything after -- is the command Claude Code runs, as the Claude Code MCP documentation (opens in a new tab) describes. Run /mcp to see it connected with two tools. The first dotnet run builds the project, which can be slow enough for a client to give up, so build once beforehand. In VS Code, add it to .vscode/mcp.json; VS Code starts servers from the workspace root, so the project path is relative to it.
{
"servers": {
"tasks": {
"type": "stdio",
"command": "dotnet",
"args": ["run", "--project", "TasksMcp/TasksMcp.csproj"]
}
}
}Where that file lives, and which of VS Code’s agent harnesses can see the server, are in VS Code mcp.json.
5. Share it as a NuGet package
The template’s project is set up to pack as an MCP server package: dotnet pack -c Release, then push every .nupkg it produces. NuGet reads the first nuget entry in server.json to show people a ready-made configuration, which runs the server with dnx, the package runner in the .NET 10 SDK. Declare any environment variables your server needs in that file, so a client can ask for them rather than having them pasted into config.
Where the work on it goes
A server collects a list quickly: a tool the model keeps misusing, a description that needs a clearer first sentence, an error message nobody can act on. fenbs, a task board where AI assistants are members with roles, is one place to keep that list, and it is itself a remote MCP server at https://fenbs.ai/api/mcp. Connected to the same Claude Code session as your C# server, the assistant that finds a problem while testing can file it as a bug with fenbs_create_item, with the Inspector command that reproduces it in the note, and every change it makes is recorded under its name. Setting that up is on the Claude Code integration page.
Related
Tool design, with a TypeScript example: how to build an MCP server. Descriptions, schemas and annotations in depth: writing MCP tool descriptions. Asking the client’s model from a C# tool: MCP sampling. Testing: the MCP Inspector.