.NETMCPAI AgentsC#

Building Your Own MCP Server in C#

Expose your tools to any AI agent by building an MCP server in C#. A practical walkthrough of the Model Context Protocol server SDK, defining tools, and choosing a transport.

Building Your Own MCP Server in C#

We’ve covered consuming MCP servers — pointing your agent at someone else’s tools. This article flips it around: building your own MCP server in C# so that any MCP-compatible client — your agent, a colleague’s Python agent, a VS Code Copilot session — can discover and call your capabilities over a standard protocol. If a capability needs to be shared beyond a single app, publishing it as an MCP server is how you do it.

For when to build a server versus a plain C# tool, see function calling vs MCP.

What a server actually does

An MCP server advertises a list of tools (each with a name, description, and input schema) and executes them when a client sends a tools/call request. It can also expose resources — read-only data addressed by URI, closer to a GET than a function call — and prompts, which are named, parameterised prompt templates a client can offer its user. Tools are what people build first and what the rest of this article concentrates on.

The official C# SDK, co-maintained by Microsoft and Anthropic, turns your annotated C# methods into a compliant server. It ships as five packages, and picking the wrong one is the first thing people get stuck on: ModelContextProtocol.Core is the low-level client and server API with minimal dependencies, ModelContextProtocol adds hosting and dependency injection, ModelContextProtocol.AspNetCore adds the HTTP transport, ModelContextProtocol.Extensions.Apps covers interactive UI applications, and ModelContextProtocol.Extensions.Tasks covers long-running tool invocations. For a stdio server the hosting package alone is enough.

Setting up the project

dotnet new console -n MyMcpServer
cd MyMcpServer
dotnet add package ModelContextProtocol
dotnet add package Microsoft.Extensions.Hosting

No --prerelease flag any more. The SDK went 1.0 and then straight past it: 2.0.0 shipped on 28 July 2026 and 2.2.0 on 13 August 2026, and the major bump is not cosmetic. Most tutorials you’ll find online — and, for a while, most search results claiming to describe the “latest” SDK — target 1.x. The differences are covered below, and they change how you configure a server rather than how you write a tool.

Defining your tools

Tools are C# methods decorated so the SDK can expose them. You annotate the class and methods, and describe each parameter — exactly the descriptions the client’s model reads to decide when to call your tool:

using System.ComponentModel;
using ModelContextProtocol.Server;

[McpServerToolType]
public static class OrderTools
{
    [McpServerTool, Description("Gets the delivery status of an order by its ID.")]
    public static string GetOrderStatus(
        [Description("The order ID, e.g. 'ORD-4821'")] string orderId)
    {
        // Your real logic — hit a database, call an API, etc.
        return $"Order {orderId} shipped and arrives in 2 days.";
    }

    [McpServerTool, Description("Lists open orders for a customer.")]
    public static string[] ListOpenOrders(
        [Description("The customer ID")] string customerId) =>
        new[] { "ORD-4821", "ORD-5140" };
}

The descriptions are the contract the model reads — write them as carefully as you’d write a good API doc, because they directly determine whether clients call your tools correctly. A vague description is not a documentation problem, it is a correctness problem: the model will either skip a tool it should have used or call it with garbage arguments, and you will see neither as an exception in your logs.

Wiring up the server

Register the server with the generic host, tell it to discover your tools, and pick a transport:

using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;

var builder = Host.CreateApplicationBuilder(args);

// stdio speaks JSON-RPC over stdout — logs there corrupt the stream.
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);

builder.Services
    .AddMcpServer()
    .WithStdioServerTransport()
    .WithToolsFromAssembly();

await builder.Build().RunAsync();

WithToolsFromAssembly scans for your annotated methods, so adding a new tool is just adding a new method. The logging line is not optional decoration. On stdio, stdout is the protocol channel, and a single Console.WriteLine or a default console logger interleaved with the JSON-RPC frames produces a parse error on the client with no useful message about where it came from. Send every log line to stderr.

The HTTP version is an ASP.NET Core app instead:

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddMcpServer()
    .WithHttpTransport()
    .WithToolsFromAssembly();

var app = builder.Build();
app.MapMcp();
app.Run();

Choosing a transport: stdio vs HTTP

The choice is about where the server runs. stdio runs the server as a local subprocess of the client over standard input/output — right for filesystem access, local dev utilities, and anything you ship for someone to run on their own machine. No ports, no network, and the client’s process lifetime is your process lifetime. HTTP runs the server as a remote service. Use it when the capability is centralised — a shared internal server your whole team’s agents call — and accept that it brings hosting and, crucially, auth, because a networked tool server that executes actions on request is a real attack surface (see securing agents).

Start with stdio for local and single-user tools; move to HTTP when the server needs to be shared.

The 2.x change that matters: sessions became per-request metadata

Here is the one-sentence version. The SDK splits every protocol revision into two buckets — versions that negotiate once through an initialize handshake and keep a session, and versions where every request carries its own protocol version, client info and capabilities. The 2026-07-28 revision is in the second bucket. Two proposals did the damage: one removed the Mcp-Session-Id header, the other removed the initialize handshake, so requests on that revision can only ever be served statelessly.

Everything else that looks like an unrelated breaking change is a consequence of that decision. HttpServerTransportOptions.Stateless now defaults to true. A tools/list call answers immediately with no initialize first, which on 1.x was a protocol error. HTTP POSTs must carry Mcp-Method on every request and Mcp-Name on tools/call, resources/read and prompts/get. And the per-request metadata rides in _meta under namespaced keys — io.modelcontextprotocol/protocolVersion, not protocolVersion. A plain key is silently ignored, which is the worst kind of failure because nothing errors until a later check notices the value is missing.

What replaced the session is a cache. A server/discover call returns the supported versions, capabilities and instructions along with a time-to-live and a cache scope that behaves like HTTP’s Cache-Control: public and Cache-Control: private — public results may be stored by a shared gateway and served to any user, private results only by the requesting user’s own client. Tool, resource and prompt listings carry the same caching hints. State that used to live in a session on your server now lives in a cache with an explicit TTL and an explicit sharing rule.

Do not “get sessions back” by setting Stateless = false

This is the trap, and it is easy to get backwards. Stateless is a convenience proxy over the real setting, SessionMode, which has three values, not two:

Session mode Old clients (2025-11-25 and earlier) 2026-07-28 clients
Stateless (default) no session served per request
Stateful full session, Mcp-Session-Id refused, -32022 UnsupportedProtocolVersion
StatefulForInitializeClients full session, Mcp-Session-Id served per request

So the obvious move — “I need sampling back, I’ll set Stateless = false” — quietly assigns Stateful and starts rejecting every modern client with a protocol error. That refusal is deliberate: it exists so a dual-path client downgrades to the handshake rather than failing outright. But a client that only speaks the new revision has nothing to downgrade to. For a mixed fleet, the setting you almost certainly want is:

builder.Services
    .AddMcpServer()
    .WithHttpTransport(options =>
    {
        options.SessionMode = HttpServerSessionMode.StatefulForInitializeClients;
    })
    .WithToolsFromAssembly();

That serves both generations on one endpoint and lets you migrate progressively. Note that reading Stateless returns true only when SessionMode is Stateless, so StatefulForInitializeClients reads back as false — and both properties write the same field, so if you set them both, the last assignment wins.

What stateless mode actually costs you

Tools, resources and prompts are unaffected. What goes away is anything that needs the server to call back to the client: sampling, elicitation and roots. SessionId is null, Mcp-Session-Id is unused, and the GET, DELETE and /sse endpoints return 405. If your server was designed around asking the client’s model a question mid-tool, that design no longer holds.

The replacement is multi round-trip requests. Instead of pushing a request down a session channel, the handler suspends and returns the request inside its own response; the client fulfils it and answers on the next round trip, carrying sampling or elicitation parameters as appropriate. Same capability, no server-to-client channel, and nothing that has to land on the same process twice — which is the actual point, because it means you can scale a tool server behind a load balancer without sticky sessions.

Structured output is opt-in, per tool

The 2.x release notes say non-object results now emit raw values instead of the old {"result": 72} wrapper. That is true, but only once you ask for it. By default a 2.2.0 server emits no structuredContent and no outputSchema at all — just a text content block. You turn it on per tool:

[McpServerTool(UseStructuredContent = true), Description("Current temperature for a city.")]
public static int TemperatureFor(string city) => 34;

The shape you then get depends on the negotiated protocol version, not on the SDK version. On 2025-06-18 the same compiled tool emits "structuredContent": {"result": 34} with an object output schema; on 2026-07-28 it emits "structuredContent": 34. The SDK rewrites the newer natural shape into the legacy envelope when it detects an older client, so this is a negotiated difference rather than a hard break — an old client keeps working against a new server. And only non-object results move: a tool returning a record produces an identical payload under both revisions, because an object is already an object and there is nothing to unwrap.

The per-tool opt-in catches people out. Forget the attribute argument on one tool and it emits no structured content at all, which looks exactly like a protocol problem and is not.

Failure modes worth knowing before you hit them

Calling a 2026-07-28 server from anything other than the SDK means supplying, by hand, everything a session used to hold. Each of these produces a distinct error until satisfied:

Missing Error
_meta/io.modelcontextprotocol/protocolVersion -32602 requests using that version must include it
Mcp-Method header -32020 Missing required Mcp-Method header.
Mcp-Name header -32020 Missing required Mcp-Name header.
_meta/io.modelcontextprotocol/clientCapabilities -32602, must be present as a JSON object

From C#, none of this is your problem — the client SDK negotiates the version and writes the headers and _meta itself. That asymmetry is worth stating plainly to your consumers: use the SDK and the 2.x migration is a non-event; call your server from curl, a gateway, or a language without an SDK, and you must send all of it yourself.

Three local gotchas cost real time. dotnet run obeys Properties/launchSettings.json and ignores ASPNETCORE_URLS, so pass --urls or your server will not be on the port you think. Rebuilding while the server is running fails with MSB3027, a file lock, not a compile error. And the Tasks API — the task store, task status and execution types — was removed from the main package and extracted to ModelContextProtocol.Extensions.Tasks; if you used it, that package reference is your entire migration.

One myth to put down: the natural assumption is that 1.x used a client factory and an SSE transport type that 2.x renamed. Diffing the public type lists in the shipped XML documentation says otherwise — the modern client and HTTP transport type names already existed in 1.4.0. Client-side names barely moved. The break is on the wire, not in the API names.

When not to build one

If exactly one application you control calls the capability, an in-process function tool is cheaper in every dimension: no second process, no transport, no schema round trip, no auth story, and a stack trace that crosses the call. MCP earns its overhead when the consumer is someone else’s agent, in someone else’s language, on someone else’s release schedule. Publishing a server for a tool only you call is how you end up debugging JSON-RPC framing to fetch a row from your own database.

Testing it

Because your server is a standard MCP server, any MCP client can exercise it — your own .NET agent using the client SDK, or the MCP Inspector for manual poking. List its tools and call one; if the description and schema look right to that client, they’ll look right to every client. The check worth adding to CI is a call over the transport you actually ship, not a unit test of the method, because the interesting failures live in schema generation and protocol negotiation rather than in your tool body.

That is the server side. Consuming MCP servers from .NET covers the client, where the harder problems live — untrusted tool descriptions, transport trust and lifecycle.

Note: version numbers and defaults move. The behaviour described here was verified against ModelContextProtocol.AspNetCore 2.2.0 on .NET 10. Check the official C# SDK repo for anything newer; the shape — annotate methods, register the server, pick a transport — is the stable part.

Takeaway

Building an MCP server in C# is still mostly writing good tools: annotate methods, describe them well, register the server, and choose stdio for local use or HTTP for a shared service. What 2.x changed is the configuration around them. Sessions gave way to per-request metadata and caching, stateless became the HTTP default, structured output became an explicit per-tool opt-in, and Stateless = false became a trap that rejects modern clients instead of restoring the old behaviour. Reach for StatefulForInitializeClients if you need sessions for older clients, keep your logs off stdout, and remember that the payoff is reach — a capability you’d otherwise reimplement per app becomes a standard tool that any MCP client, in any language, can discover and call.

Next: if that server is going to be reachable over HTTP rather than stdio, securing a remote MCP server in C# covers what the spec requires before you expose it.


Have a correction or a topic you want covered? Email mani.bc72@gmail.com.