MCP Deep Dive, Part 12: Building MCP Servers in C# and .NET 9 — The SDK, DI, and Native AOT
The MCP C# SDK turns a typed method into a tool with an auto-generated schema. Here's building MCP servers in .NET 9 — DI-native, testable, AOT-ready.
- Author
- Randhir Jassal
- Published
- Reading time
- 9 min read
- Views
- 18 views
We've built servers, clients, tools, auth, and governance across this series — all in C#, without ever slowing down to look at how the .NET tooling makes it pleasant. This part does. If your backend is already .NET, an MCP server turns out to be one of the highest-leverage things you can build: a thin, attributed, DI-native surface over the domain services you already own. Here's the stack that makes it so.
This is Part 12 of a 15-part deep dive on Model Context Protocol (MCP). Part 3 built a production server treating C# as the vehicle; this part is about the vehicle itself — the MCP C# SDK, the attribute-to-schema model, dependency injection, the two hosting models, testing, and Native AOT. We stay on Mattrx's mattrx-analytics, and leave the Azure hosting depth to Part 13.
TL;DR
| Concern | Hand-rolled (before) | MCP C# SDK (after) |
|---|---|---|
| Protocol | parse JSON-RPC by hand | AddMcpServer() owns it |
| Tool schema | hand-written JSON Schema | auto from the [McpServerTool] method |
| Dependencies | new-up / statics | constructor DI, scoped per call |
| Transport | locked | stdio host or ASP.NET Core, same tools |
| Testing | run the whole agent | unit-test methods + in-memory transport |
| Cold start | fat JIT | trimming / Native AOT |
- The MCP C# SDK (
ModelContextProtocol+ModelContextProtocol.AspNetCore) owns JSON-RPC,initialize, negotiation, and schema generation. - Attributes → schema:
[McpServerTool]on a typed method auto-generates the JSON Schema (params → properties, non-nullable → required,[Description]→ docs). - Tools are DI types — resolved scoped per call, reusing your domain services,
AiPrincipal, andDbContext. WithToolsFromAssembly()discovers every[McpServerToolType]— no manual registry.- One tool codebase, two hosts — stdio via the generic host, HTTP via ASP.NET Core.
- Testable: tools are plain methods (unit tests) + an in-memory transport (integration tests) — no model, no network.
- Native AOT / trimming cut cold start for scale-out on Container Apps (validate trim-safety first).
- Reuses
ICampaignQueriesand friends → the server adds almost no new query code (Part 3). - Plays with Microsoft.Extensions.AI (
IChatClient) on the client side. - Read-tool p95 120 ms, error rate 0.8%, ~85k calls/day — the Part 3 numbers, on this stack.
The one mental shift: in .NET, an MCP tool is just an attributed method with constructor dependencies. The SDK turns your typed signature into the schema, resolves the tool from your existing DI container, and owns the protocol — so building an MCP server is mostly exposing the services you already have.
Should you even use .NET for this?
Quick honesty first, because it's the question everyone asks: the Python and TypeScript SDKs are the most mature MCP ecosystems, and if your stack is Python/TS, use those. The reason to build MCP servers in .NET is singular but decisive — your domain already lives there. An MCP server should sit next to the data and logic it exposes and reuse them; rewriting a C# domain in Python just to speak MCP is pure waste. Mattrx's servers are .NET because Mattrx is .NET. With that settled, here's what the SDK gives you.
What you write vs what the SDK does
YOU WRITE THE SDK DOES
--------- -----------
Program.cs (AddMcpServer, -> JSON-RPC framing + initialize + negotiation
transport, WithTools...) tools/list, resources/list (with pagination)
[McpServerToolType] classes -> reflect method signatures -> JSON Schema
[McpServerTool] methods -> dispatch tools/call -> resolve tool from DI -> invoke
DI registrations (your domain) -> scoped instance per call, CancellationToken threaded,
structured results + errors serialized
1. The SDK owns the protocol
Before
Part 3's first instinct — hand-parse JSON-RPC and reimplement initialize, negotiation, and schema generation. Effort spent re-creating a solved problem.
After
Add two NuGet packages and let the SDK own the protocol.
<PackageReference Include="ModelContextProtocol" Version="*" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="*" />
// The SDK owns JSON-RPC, initialize, capability negotiation, and schema generation.
builder.Services
.AddMcpServer(o => o.ServerInfo = new() { Name = "mattrx-analytics", Version = "2.4.0" })
.WithHttpTransport()
.WithToolsFromAssembly(); // discover every [McpServerToolType] in this assembly
Diagnostic: WithToolsFromAssembly() reflects your attributed classes into tools and wires the JSON-RPC dispatch; you register capabilities, not plumbing. Everything below is about the capabilities — which, in .NET, are just types and methods.
2. Attributes generate the schema
Before
Hand-writing a JSON Schema for every tool — verbose, and it drifts from the actual method.
After
Attribute a typed method; the SDK generates the schema from the signature.
[McpServerToolType]
public sealed class AnalyticsTools(ICampaignQueries campaigns, AiPrincipal principal)
{
[McpServerTool(Name = "get_campaign_kpis")]
[Description("Return a campaign's KPI snapshot for a date range.")]
public async Task<CampaignKpis> GetCampaignKpis(
[Description("Campaign id (GUID) in the caller's tenant.")] string campaignId,
[Description("ISO-8601 range, e.g. 2026-06-01/2026-06-30.")] string range,
CancellationToken ct)
=> await campaigns.GetKpisAsync(principal.TenantId, campaignId, DateRange.Parse(range), ct);
}
The SDK turns that signature into the JSON Schema the model consumes — automatically:
{
"name": "get_campaign_kpis",
"description": "Return a campaign's KPI snapshot for a date range.",
"inputSchema": {
"type": "object",
"properties": {
"campaignId": { "type": "string", "description": "Campaign id (GUID) in the caller's tenant." },
"range": { "type": "string", "description": "ISO-8601 range, e.g. 2026-06-01/2026-06-30." }
},
"required": ["campaignId", "range"]
}
}
Diagnostic: this is the .NET killer feature. Your typed signature is the schema — parameters become properties, non-nullable parameters become required, and [Description] becomes the descriptions the model reads (Part 5). There is no hand-written, drift-prone JSON Schema to maintain.
3. Tools are DI-native
Before
Tools new up their own dependencies or reach for statics — untestable, and duplicating what your app already wires.
After
Constructor injection. The SDK resolves a scoped tool instance per call from the same DI container as the rest of your app.
// Register your domain services once; tools consume them by constructor injection.
builder.Services.AddScoped<ICampaignQueries, CampaignQueries>();
builder.Services.AddScoped(sp => sp.GetRequiredService<IPrincipalAccessor>().Current); // AiPrincipal
// AnalyticsTools' constructor params (ICampaignQueries, AiPrincipal) are injected per call.
Diagnostic: this is the reason .NET is a strong MCP host. A tool is a class with constructor dependencies, resolved scoped to the call exactly like an MVC controller — so it reuses the domain services, the request's AiPrincipal, and a scoped DbContext you already have. The MCP server becomes a thin governed surface, not a re-implementation of your backend.
Mattrx metric: because tools inject the existing ICampaignQueries, exposing the analytics domain over MCP added almost no new query code — the win from Part 3, and it's DI that makes it true.
4. Two hosting models, one tool codebase
Before
The transport was baked in — locked to HTTP, or locked to stdio.
After
The same AnalyticsTools hosts two ways: a console app over stdio (via the generic host), or an ASP.NET Core app over Streamable HTTP.
// stdio host — a console app for local dev / desktop tools. Same AnalyticsTools.
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddMcpServer().WithStdioServerTransport().WithToolsFromAssembly();
await builder.Build().RunAsync();
// HTTP host — ASP.NET Core for production / multi-tenant. Same AnalyticsTools.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMcpServer().WithHttpTransport().WithToolsFromAssembly();
var app = builder.Build();
app.MapMcp("/mcp");
app.Run();
Diagnostic: write the capability once; choose the host by deployment. The generic-host console over stdio is Part 2's local-dev transport; the ASP.NET Core app over Streamable HTTP is production — where the MCP endpoint composes with the auth (Part 6), OTel (Part 10), and health checks (Part 3) middleware you already run. Transport-by-trust-boundary, in code.
5. MCP servers you can actually test
Before
The only way to check a tool was to run the whole agent against it and read the output.
After
Tools are plain methods — unit-test them directly — and an in-memory transport integration-tests the whole server without a model or a network.
// Unit test: a tool is an ordinary method with injected fakes.
[Fact]
public async Task GetCampaignKpis_is_tenant_scoped()
{
var tools = new AnalyticsTools(new FakeCampaignQueries(), TestPrincipal);
var kpis = await tools.GetCampaignKpis("4821", "2026-06-01/2026-06-30", default);
Assert.Equal(0.021, kpis.Ctr);
}
// Integration test: connect a client to the server over an in-memory transport.
[Fact]
public async Task Server_lists_and_calls_tools_over_the_protocol()
{
await using var client = await McpTestHost.ConnectInMemoryAsync<AnalyticsTools>();
var tools = await client.ListToolsAsync();
Assert.Contains(tools, t => t.Name == "get_campaign_kpis");
}
Diagnostic: because tools are DI methods, the logic unit-tests like any service — no protocol, no model. And an in-memory transport exercises tools/list and tools/call end to end, fast and deterministic — the CI-friendly counterpart to Part 10's MCP Inspector.
6. Native AOT and packaging
Before
A fat, JIT-compiled app with a slow cold start — painful when Container Apps scales out under load.
After
Trim and (where trim-safe) Native-AOT-publish into a small, fast-starting container.
<PropertyGroup>
<PublishTrimmed>true</PublishTrimmed>
<InvariantGlobalization>true</InvariantGlobalization>
<!-- Native AOT for the fastest cold start on scale-out. VALIDATE trim/AOT support first. -->
<PublishAot>true</PublishAot>
</PropertyGroup>
Diagnostic: an MCP server on Container Apps scales out with demand, so cold start is a real cost. Trimming shrinks the image and Native AOT slashes startup — with one caveat: reflection-based schema generation isn't automatically trim-safe, so validate (or lean on source generators) before flipping PublishAot. Don't cargo-cult it. The full Azure hosting story — scaling, revisions, zero-downtime — is Part 13.
Two hosting models, one class
the SAME AnalyticsTools class
/ \
Host.CreateApplicationBuilder WebApplication.CreateBuilder
.WithStdioServerTransport .WithHttpTransport
| |
console app (stdio) ASP.NET Core (Streamable HTTP + SSE)
local dev / desktop production / multi-tenant / Container Apps
The numbers, in one place
| Aspect | Hand-rolled (before) | MCP C# SDK (after) |
|---|---|---|
| New query code to expose the domain | a lot | almost none (DI reuse) |
| Tool schema | hand-written, drifts | auto-generated from signatures |
| Tool tests | run the agent | unit + in-memory, in CI |
| Transports supported | one | stdio + HTTP, same code |
| Cold start | fat JIT | trimmed / AOT |
| Read-tool p95 · error rate | — | 120 ms · 0.8% |
.NET MCP checklist
- Add
ModelContextProtocol(+.AspNetCore);AddMcpServer()+WithToolsFromAssembly(). - Write tools as
[McpServerToolType]classes with[McpServerTool]methods; let attributes drive the schema. - Inject domain services via the constructor; register them scoped; never capture per-request state in a singleton.
- Keep tool methods thin over the domain — logic lives in services, not tools.
- Pick the host by deployment: stdio (generic host) for local, ASP.NET Core for production.
- Compose the MCP endpoint with your existing auth / OTel / health middleware.
- Unit-test tools as methods; integration-test the server over an in-memory transport.
- Trim and (if trim-safe) Native-AOT publish for cold start; version-pin the SDK.
The honest stuff: pitfalls and when to pick Python instead
- A pure Python/TS shop. Use the Python or TypeScript SDK — don't add a .NET runtime just to build an MCP server. The reason to pick .NET is an existing .NET domain.
- Fighting the schema generator. If you're hand-crafting JSON Schema in .NET, you're likely misusing the SDK. Let attributes drive it.
- Flipping
PublishAotblindly. Reflection-based schema generation may not be trim-safe. Validate, or use source generators — AOT is not free. - Fat tool methods. Business logic in a tool is untestable and unreusable. Keep tools as thin adapters over domain services.
- Singleton tools with request state. A tool that holds an
AiPrincipalmust be scoped. A singleton capturing per-request state is a cross-tenant bug waiting to happen. - Ignoring the middleware you already have. Auth, OTel, and health checks compose with the MCP endpoint. Reuse them; don't re-invent per server.
- Chasing SDK churn. The C# SDK moves faster than the more-settled Python/TS ones. Pin the version and test upgrades deliberately.
The model to carry forward
The MCP C# SDK is at its best when the server is a thin, attributed, DI-native surface over domain services you already own. Write the tool as a typed method, let the SDK generate the schema and own the protocol, host it over stdio or ASP.NET Core, and test it as plain methods. The value of .NET here isn't a new framework to learn — it's reusing the one you already have, which is exactly why an MCP server is such high leverage on an existing .NET backend.
Three habits that make .NET MCP servers clean:
- Let attributes generate the schema. A typed method with
[Description]is the contract — never hand-write JSON Schema. - Keep tools thin and DI-native. Resolve domain services, thread the
CancellationToken, and leave the logic in the domain. - Test tools as methods. Unit-test the logic, in-memory-test the protocol — no model in the loop.
In Part 13 we take this server to production infrastructure: hosting MCP on Azure at real scale — Container Apps, scaling, revisions, and the gateway in front.
Continue the series — MCP Deep Dive
- Why Model Context Protocol Kills Integration Glue Code for Good
- Inside the MCP Architecture: Hosts, Clients, and Servers
- Build a Production-Grade MCP Server From Scratch
- Build an MCP Client That Connects to Any Tool (and Any Model)
- Custom MCP Tools Your AI Agents Can Actually Trust
- MCP Authentication With OAuth and Entra ID, Done Right
- Reaching a Tool Isn't Being Allowed — Least-Privilege Authorization for MCP Agents
- When a Tool Result Is the Attack — Securing MCP Against Prompt Injection and Tool Abuse
- When the Tool Takes Minutes — Streaming and Long-Running Tools Over MCP
- When the Agent Feels Off — Debugging and Observability for MCP in Production
- The Protocol Was Never the Blocker — Rolling MCP Across the Enterprise
- Building MCP Servers in C# and .NET 9 — The SDK, DI, and Native AOT (you are here)
- Hosting MCP on Azure at Real Scale
- Wiring MCP Into OpenAI and Agent Frameworks
- Running MCP in Production — Lessons From Mattrx
Further reading
- MCP Deep Dive, Part 3: Build a Production-Grade MCP Server From Scratch
- MCP Deep Dive, Part 13: Hosting MCP on Azure at Real Scale
- MCP Deep Dive, Part 14: Wiring MCP Into OpenAI and Agent Frameworks
Building MCP servers on .NET and want a second pair of eyes on the SDK, DI, or AOT setup? I'm always happy to compare notes — reach me at randhir.jassal@gmail.com.
Get the next issue
A short, curated email with the newest posts and questions.