Publishing 27 Sep 2026 · 5 min read

How to build an MCP server in C# (.NET) with the official SDK

An attribute turns a method into a tool and the SDK handles the protocol. Two lines it does not write for you decide whether the server works: the one that keeps logs off stdout, and the exception type whose message reaches the model.

MR mcprush team · The people who run the gateway

An MCP server in C# is mostly ordinary .NET: a host, dependency injection and a class of tools. The official C# SDK, which Microsoft built with Anthropic, turns an attributed method into a tool and reads the schema off its signature. Three things differ from the other SDKs, and each costs an afternoon in production: where the default logger writes, which exceptions the model can read, and what the template installs. The TypeScript and Python guides build the same server.

Which package, and where to start

Most servers need ModelContextProtocol, with hosting, dependency injection and attribute discovery. ModelContextProtocol.AspNetCore adds Streamable HTTP, and ModelContextProtocol.Core is the low-level API. All three are at 2.2.0. Microsoft’s template sets a project up to pack for NuGet, but template 1.2.1 pins the SDK at 1.2.0, a major version behind, and Microsoft.Extensions.Hosting at 8.0.1, from the .NET 8 line that leaves support on 10 November 2026. Update both straight away.

# the template (needs the .NET 10 SDK), then replace its pinned versions dotnet new install Microsoft.McpServer.ProjectTemplates dotnet new mcpserver -n Issues && cd Issues dotnet add package ModelContextProtocol dotnet add package Microsoft.Extensions.Hosting # or by hand: the same two packages on top of this dotnet new console -n Issues
dotnet add package without a version takes the latest stable. Skip the --prerelease older tutorials print: the SDK has been stable since 1.0.

The minimum C# MCP server

Over stdio the client owns your process’s stdout, so anything there that is not protocol is a broken frame. In C# the host writes one for you: its console logger goes to stdout by default. One line sends every level to stderr. The template has it; a server written from scratch has to add it.

using System.ComponentModel; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using ModelContextProtocol; using ModelContextProtocol.Server; var builder = Host.CreateApplicationBuilder(args); builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace); // every level to stderr builder.Services.AddSingleton<IssuesApi>(); // your client for the real system builder.Services .AddMcpServer(o => o.ServerInfo = new() { Name = "issues", Version = "1.0.0" }) .WithStdioServerTransport() .WithTools<IssueTools>(); await builder.Build().RunAsync(); public class IssueTools(IssuesApi api) { [McpServerTool, Description("Creates one issue in an existing project and returns its key. Fails if the project does not exist; does not create it.")] public async Task<string> CreateIssueAsync( [Description("Project key, e.g. INFRA")] string project, [Description("One-line summary, at least 3 characters")] string title) { var issue = await api.CreateIssueAsync(project, title); return $"Created {issue.Key}: {issue.Url}"; } }
CreateIssueAsync is published as create_issue: the SDK drops Async and snake-cases the rest. A fresh IssueTools is built for every call, so constructor injection works.

In Claude Code that is claude mcp add issues -- dotnet run --project /absolute/path/to/Issues. Other clients take the same command; the config reference has each file. And never Console.WriteLine: it goes to stdout whatever the logger is set to.

What the model reads from your attributes

The model never sees the method body. It gets a name, from the method unless [McpServerTool(Name = "…")] overrides it; a description, from [Description] on the method and on each parameter; and a JSON schema, from the parameter types. The SDK’s source generator can also turn /// <summary> comments into descriptions on partial methods.

One attributed method, as tools/list sends it. The parameters the SDK injects are not in the schema, and neither is anything inside the braces.One attributed method, as tools/list sends it. The parameters the SDK injects are not in the schema, and neither is anything inside the braces.
One attributed method, as tools/list sends it. The parameters the SDK injects are not in the schema, and neither is anything inside the braces.
  • Mark the tools that only read. Unless you say otherwise, a tool counts as destructive and open-world, so set ReadOnly = true wherever it is true.
  • Validate in the method. [Required] and [MaxLength] shape the schema, but the SDK does not enforce them at runtime.
  • Some parameters are not arguments. CancellationToken, IProgress<ProgressNotificationValue>, ILogger<T> and other DI services are injected and left out of the schema.

Errors: the one exception type the model can read

Almost anything a tool throws becomes a result with isError: true, but only an McpException keeps its message. Anything else reaches the model as the bare An error occurred invoking 'close_issue'. The SDK withholds the original text in case it is sensitive. The model knows it failed but not why, so it tries again.

[McpServerTool, Description("Closes one issue. Irreversible. Fails if the key is unknown or the issue is already closed.")] public async Task<string> CloseIssueAsync([Description("Issue key, e.g. INFRA-42")] string key) { var issue = await api.GetIssueAsync(key) ?? throw new McpException($"No issue called {key}. list_open_issues shows the keys that exist."); if (issue.Closed) throw new McpException($"{key} is already closed; nothing was changed."); await api.CloseIssueAsync(key); return $"Closed {key}."; }
The model reads "An error occurred invoking 'close_issue': No issue called INFRA-9…". Throw InvalidOperationException instead and everything after the colon is gone.
Three exceptions, three different things on the wire. Only the top row gives the model a reason and a next step.Three exceptions, three different things on the wire. Only the top row gives the model a reason and a next step.
Three exceptions, three different things on the wire. Only the top row gives the model a reason and a next step.

McpException is in the ModelContextProtocol namespace, and returning a CallToolResult with IsError = true works too. McpProtocolException is for malformed calls and becomes a JSON-RPC error instead.

The same tools over Streamable HTTP in ASP.NET Core

When the server should hold the credential rather than the user’s laptop, it needs an address: dotnet new web and ModelContextProtocol.AspNetCore. The tool class does not change.

using ModelContextProtocol.AspNetCore; var builder = WebApplication.CreateBuilder(args); builder.Services.AddSingleton<IssuesApi>(); builder.Services.AddMcpServer() .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless) .WithTools<IssueTools>(); var app = builder.Build(); app.MapMcp("/mcp"); app.Run("http://localhost:3001"); // in a container: app.Run(), and let ASPNETCORE_URLS decide
Stateless is the default already. Setting it spares the next reader having to know that.

Since 2.0, which implements the 2026-07-28 revision, the transport keeps no Mcp-Session-Id and no state between requests unless you choose HttpServerSessionMode.Stateful, which unsolicited notifications and resource subscriptions need (more in the transports explainer). Kestrel does not validate the Host header by default, so set AllowedHosts to localhost;127.0.0.1;[::1] locally and your exact host names in production, never *. Then put authentication in front: a scheme that validates the token, such as AddJwtBearer, with the SDK’s .AddMcp() as the challenge scheme, which points clients at the protected-resource metadata; then app.UseAuthentication(), app.UseAuthorization() and app.MapMcp("/mcp").RequireAuthorization(). The authentication guide covers the options.

Testing it with the inspector

# the stdio build, from its folder: the web UI, then the same checks for CI npx @modelcontextprotocol/inspector dotnet run npx @modelcontextprotocol/inspector --cli dotnet run --method tools/list npx @modelcontextprotocol/inspector --cli dotnet run --method tools/call --tool-name close_issue --tool-arg key=NOPE-1 # the HTTP build, while it runs npx @modelcontextprotocol/inspector --cli http://localhost:3001/mcp --transport http --method tools/list
The close_issue call should fail with your sentence, not the generic one, and exit with code 5, as any tools/call returning isError does. Inspector 2 needs Node 22.19 or later.

Shipping it: a NuGet tool that dnx runs, or a container

A local .NET server ships as a .NET tool on NuGet, and clients start it with dnx, part of the .NET SDK from version 10, which runs it from the NuGet cache without installing it. NuGet.org encourages the McpServer package type and an embedded .mcp/server.json, and Microsoft recommends self-contained packages. The template’s project file has all of it. Fill in .mcp/server.json too: NuGet.org builds the client config from its first nuget entry, so its identifier and version must match PackageId and PackageVersion; the template ships placeholders.

<!-- Issues.csproj: the lines that make it an installable MCP server --> <PackAsTool>true</PackAsTool> <PackageType>McpServer</PackageType> <PackageId>YourName.Issues.Mcp</PackageId> <PackageVersion>1.0.0</PackageVersion> <RuntimeIdentifiers>win-x64;win-arm64;osx-arm64;linux-x64;linux-arm64;linux-musl-x64</RuntimeIdentifiers> <PublishSelfContained>true</PublishSelfContained> <None Include=".mcp\server.json" Pack="true" PackagePath="/.mcp/" /> # then pack, push every package, and this is the line a client runs dotnet pack -c Release dotnet nuget push bin/Release/*.nupkg --api-key <key> --source https://api.nuget.org/v3/index.json dnx YourName.Issues.Mcp@1.0.0 --yes
dotnet pack writes one tool package plus one per runtime identifier. A platform whose package was not pushed cannot start the server.

The command "dnx" needed to run … was not found means the user has no .NET 10 SDK. The HTTP build does not go on NuGet, which carries local servers only. It ships as an image, and dotnet publish --os linux --arch x64 /t:PublishContainer builds one without a Dockerfile. The key goes in an environment variable, never in the image. Publishing a server people install covers the README.

The same IssueTools class in both rows. What changes is who starts the process, and whose machine holds the key.The same IssueTools class in both rows. What changes is who starts the process, and whose machine holds the key.
The same IssueTools class in both rows. What changes is who starts the process, and whose machine holds the key.

.NET servers in our catalogue worth reading

  • RoselineMCP: stdio on SDK 2.2, packed as an McpServer.
  • .NET: WithTools<T> on partial methods that return CallToolResult, plus a call-tool filter that turns any stray exception into an isError result with secrets redacted (SDK 1.4).
  • Dotnet Template: stdio and HTTP from one Program.cs.
  • Devart MCP Server for SQL Server: a stdio runner on Host.CreateEmptyApplicationBuilder, which prints nothing.
  • Fuse: ModelContextProtocol.Core alone, with hand-written list and call handlers.
  • Microsoft NuGet, Microsoft’s own, which clients start with dnx NuGet.Mcp.Server --yes.
  • Excel MCP Server (FsOpenXmlDsl): the same SDK from F#; other routes to a workbook are in the Excel MCP server guide.

More in the catalogue under dotnet and C#. Microsoft’s own SQL MCP Server is a .NET tool too; MCP servers for the Microsoft stack covers it.

The checklist

Before you publishWhy
LogToStandardErrorThreshold = LogLevel.Trace; no Console.WriteLinestdout carries the protocol
Packages on 2.x, not the template’s 1.2.0The template is a major version behind
A [Description] on every tool and parameterIt is the only text the model reads
Expected failures throw McpExceptionOther exceptions reach the model without a reason
ReadOnly = true wherever it is trueUnmarked tools count as destructive
HTTP: AllowedHosts set, auth in front of MapMcp()Kestrel accepts any Host header by default
.mcp/server.json identifier and version match the packageNuGet.org generates the install line from it
Every .nupkg pushed, the dnx line testedA missing platform package means no start

Most of this is ordinary .NET, which is the point of the SDK. The two lines that are not, the logger threshold and the exception type, are where to look first when a client reports a disconnect or a model keeps retrying.