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.
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 IssuesThe 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}";
}
}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.
- 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}.";
}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 decideSince 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/listShipping 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 --yesThe 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.
.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 publish | Why |
|---|---|
| LogToStandardErrorThreshold = LogLevel.Trace; no Console.WriteLine | stdout carries the protocol |
| Packages on 2.x, not the template’s 1.2.0 | The template is a major version behind |
| A [Description] on every tool and parameter | It is the only text the model reads |
| Expected failures throw McpException | Other exceptions reach the model without a reason |
| ReadOnly = true wherever it is true | Unmarked 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 package | NuGet.org generates the install line from it |
| Every .nupkg pushed, the dnx line tested | A 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.