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.

Terminal
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

Terminal
dotnet new console -n TasksMcp && cd TasksMcp
dotnet add package ModelContextProtocol
dotnet add package Microsoft.Extensions.Hosting
Program.cs
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();
TaskTools.cs
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. TaskStore is a singleton, and the SDK creates TaskTools with it. A tool method can also take McpServer, CancellationToken, IProgress<ProgressNotificationValue>, ClaimsPrincipal or 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 called ListOpenTasks becomes list_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": true in the schema.
  • ReadOnly, Destructive, Idempotent and OpenWorld on the attribute become the readOnlyHint, destructiveHint, idempotentHint and openWorldHint annotations. Writing MCP tool descriptions covers when to set each.
  • Errors: an McpException becomes a result with isError: 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. McpProtocolException becomes 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 = true on [McpServerTool] and the SDK publishes an output schema for the return type and puts the value in structuredContent.
  • Richer content: return an ImageContentBlock, an AudioContentBlock, an EmbeddedResourceBlock or 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 an Mcp-Param-Region header, 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:

Program.cs (dotnet new web; dotnet add package ModelContextProtocol.AspNetCore)
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

Terminal
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

Claude 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.

.vscode/mcp.json
{
  "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.

Questions people ask.

Is there an official C# SDK for MCP?

Yes. The C# SDK is maintained in the Model Context Protocol organisation on GitHub and published on NuGet as ModelContextProtocol, ModelContextProtocol.AspNetCore and ModelContextProtocol.Core. Version 2.2.0 was current on 28 September 2026.

Is there a dotnet new template for MCP servers?

Yes. Install Microsoft.McpServer.ProjectTemplates with dotnet new install, then run dotnet new mcpserver. It needs the .NET 10 SDK, is marked as preview, and lets you choose a local stdio or remote HTTP server.

How do I return an error the model can read from a C# MCP tool?

Throw McpException with a sentence that says what went wrong and what to do next. The SDK returns it as a tool result with isError set to true. Other exceptions reach the model only as a generic message, and McpProtocolException becomes a JSON-RPC error.

Should a C# MCP server over HTTP be stateless?

For most servers, yes. Stateless is the SDK default and matches the 2026-07-28 protocol, which has no sessions, while still answering older clients. Choose stateful only if you need unsolicited notifications, resource subscriptions or per-client state.

Start with one thing.

There is nothing to set up first. Write one line and you’ve started.