DEV Community

kirandeepjassal-crypto
kirandeepjassal-crypto

Posted on • Originally published at prepstack.co.in

MCP Deep Dive, Part 12: Building MCP Servers in C# and .NET 9 — The SDK, DI, and Native AOT

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.

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.

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

Should you even use .NET for this?

Quick honesty first: 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.

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
Enter fullscreen mode Exit fullscreen mode

1. The SDK owns the protocol

Add two NuGet packages and let the SDK own JSON-RPC, initialize, capability negotiation, and schema generation.

<PackageReference Include="ModelContextProtocol" Version="*" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="*" />
Enter fullscreen mode Exit fullscreen mode
builder.Services
    .AddMcpServer(o => o.ServerInfo = new() { Name = "mattrx-analytics", Version = "2.4.0" })
    .WithHttpTransport()
    .WithToolsFromAssembly();   // discover every [McpServerToolType] in this assembly
Enter fullscreen mode Exit fullscreen mode

WithToolsFromAssembly() reflects your attributed classes into tools and wires the JSON-RPC dispatch; you register capabilities, not plumbing.

2. Attributes generate the schema

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);
}
Enter fullscreen mode Exit fullscreen mode

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. There is no hand-written, drift-prone JSON Schema to maintain.

3. Tools are DI-native

Constructor injection. The SDK resolves a scoped tool instance per call from the same DI container as the rest of your app.

builder.Services.AddScoped<ICampaignQueries, CampaignQueries>();
builder.Services.AddScoped(sp => sp.GetRequiredService<IPrincipalAccessor>().Current); // AiPrincipal
// AnalyticsTools' constructor params (ICampaignQueries, AiPrincipal) are injected per call.
Enter fullscreen mode Exit fullscreen mode

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. Because tools inject the existing ICampaignQueries, exposing the analytics domain over MCP added almost no new query code.

4. Two hosting models, one tool codebase

The same AnalyticsTools hosts two ways: a console app over stdio, 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();
Enter fullscreen mode Exit fullscreen mode
// 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();
Enter fullscreen mode Exit fullscreen mode

Write the capability once; choose the host by deployment. The ASP.NET Core app is where the MCP endpoint composes with the auth, OTel, and health-check middleware you already run.

5. MCP servers you can actually test

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");
}
Enter fullscreen mode Exit fullscreen mode

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.

6. Native AOT and packaging

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>
Enter fullscreen mode Exit fullscreen mode

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 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:

  1. Let attributes generate the schema. A typed method with [Description] is the contract — never hand-write JSON Schema.
  2. Keep tools thin and DI-native. Resolve domain services, thread the CancellationToken, and leave the logic in the domain.
  3. Test tools as methods. Unit-test the logic, in-memory-test the protocol — no model in the loop.

Originally published at prepstack.co.in. Part 13 takes this server to production infrastructure: hosting MCP on Azure at real scale.

Top comments (0)