How I Finally Got an MCP Server Running with .NET 9
Last Tuesday, a Slack DM from our DevOps lead about "context window exhaustion" got me thinking. We've been hitting the limits with direct API calls to Claude Opus 4.7 for some of our internal workflow automation tools. The sheer volume of domain-specific context we need to feed was becoming unwieldy, and frankly, expensive. That's when I remembered hearing whispers about the Model Context Protocol (MCP) and how it's designed to give you more granular control over context management. I figured, why not try to build a simple mcp server csharp implementation myself?
Honestly, I was skeptical. Another protocol, another layer of abstraction. But the idea of potentially offloading complex context assembly from the model provider's API and managing it locally, closer to our data, was too tempting to ignore. My goal wasn't a production-ready behemoth, but a proof-of-concept for a model context protocol dotnet server that could accept context, make internal tool calls, and then pass a refined request to an actual LLM. I spent the next couple of weeks chipping away at it in Visual Studio 2026 using C# 13 and dotnet 9.
My First Stumble: Deconstructing the Protocol
The initial hurdle was simply understanding the MCP specification itself. It's not overly complex, but translating its concepts into idiomatic C# took a few tries. I kept getting stuck on how tools were supposed to be defined and then invoked. My first attempt at defining the core Message and ToolDefinition structures felt clunky, and I spent an embarrassing amount of time over-engineering the serialization. I envisioned a complex hierarchy, but the protocol is surprisingly flat.
What I ended up with, after several refactors and some help from Claude Sonnet 4.6 (more on that later), were simple record types. The key insight was realizing that ToolDefinition objects are just JSON schemas, and ToolCall objects are essentially method calls with arguments. Keeping it simple was crucial. This pattern holds true across the protocol: everything is a structured message, whether it's user input, assistant output, or a tool invocation.
// Simplified core MCP types I settled on
public record Message(string Role, string Content, ToolCall? ToolCall = null, ToolResult? ToolResult = null);
public record ToolCall(string Name, Dictionary<string, object> Arguments);
public record ToolResult(string ToolCallId, string Content, bool IsError = false);
public record ToolDefinition(string Name, string Description, JsonSchema Parameters); // JsonSchema from a library
The Setup I Settled On: A Minimal .NET 9 Host
After wrestling with the data structures, the server implementation itself was surprisingly straightforward, thanks to ASP.NET Core Minimal APIs in .NET 9. I wanted something lean, without a lot of boilerplate, and Minimal APIs fit the bill perfectly. My Program.cs became the central hub for defining the MCP endpoints.
I decided to encapsulate the core logic for handling invoke requests within a simple IMCPService interface. This allowed me to swap out implementations easily and kept my Program.cs clean. I found it helps to think of the MCP server as a proxy or an orchestrator. It receives a request, potentially performs some local actions (like invoking an internal tool), and then crafts a new request for the actual LLM if needed.
Here’s a snippet of my Program.cs showing how I mapped the primary /mcp/v1/invoke endpoint. I used Rider 2026 for most of this, and its refactoring tools made quick work of moving logic around.
// Program.cs snippet for MCP server
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Anthropic.MCP.Server.Models; // My custom models based on MCP spec
using Anthropic.MCP.Server.Services; // My IMCPService implementation
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddScoped<IMCPService, LocalMCPService>(); // My service for handling MCP logic
builder.Services.AddHttpClient(); // For making calls to actual LLMs
var app = builder.Build();
// Main MCP invoke endpoint
app.MapPost("/mcp/v1/invoke", async (HttpContext context, IMCPService mcpService) =>
{
// The MCP spec allows for streaming directly over HTTP/1.1 or WebSockets.
// For simplicity in this PoC, I initially focused on non-streaming, then added SSE.
// This example shows the initial non-streaming approach.
var request = await context.Request.ReadFromJsonAsync<InvokeRequest>();
if (request == null) return Results.BadRequest("Invalid MCP InvokeRequest payload.");
context.Response.Headers.ContentType = "text/event-stream";
context.Response.Headers.CacheControl = "no-cache";
context.Response.Headers.Connection = "keep-alive";
// Use a custom streaming writer for Server-Sent Events (SSE)
await mcpService.HandleInvokeStreamAsync(request, context.Response.BodyWriter);
return Results.Empty; // Response written directly to stream
});
app.Run();
Integrating with AI: Where Copilot and Claude Shined (and Failed)
The real power play came in connecting my MCP server to an actual model. My goal was to feed the processed context to Claude Opus 4.7. This meant my LocalMCPService needed to perform two main tasks: interpreting incoming tool calls from the MCP request and then, if necessary, making an API call to Anthropic's API with the refined context.
This is where GitHub Copilot for Workspaces and Cursor 0.42+ became invaluable. Writing the boilerplate for HttpClient calls, JSON serialization, and handling streaming responses from the Anthropic API is tedious. I prompted Copilot with things like "create a method to call the Anthropic messages API with streaming, given a list of Anthropic.MessagesApi.Message objects." It generated surprisingly accurate starting points, saving me hours.
Where I screwed this up initially was with the streaming responses. I tried to buffer everything before sending it back through my MCP server's SSE stream, which completely defeated the purpose. It took me a few frustrating debugging sessions to realize I needed to directly pipe the Anthropic streaming response into my own Response.BodyWriter as Server-Sent Events. My server wasn't just a proxy; it was a re-streamer.
// Simplified snippet from LocalMCPService.cs
public async Task HandleInvokeStreamAsync(InvokeRequest request, PipeWriter writer)
{
// ... logic to process incoming MCP messages and tool definitions ...
// Example: If an internal tool call is detected and handled
if (request.Messages.Any(m => m.ToolCall != null))
{
var toolCall = request.Messages.First(m => m.ToolCall != null).ToolCall;
// Simulate internal tool execution
var toolResult = await ExecuteInternalTool(toolCall);
await WriteSseEvent(writer, new InvokeResponse(new Choice("tool_result", toolResult.Content)));
// Add the tool result to the context for the LLM
request = request with { Messages = request.Messages.Append(new Message("tool", toolResult.Content, ToolResult: toolResult)).ToList() };
}
// Now, forward the refined context to the actual Anthropic API
var anthropicMessages = request.Messages.Select(MapToAnthropicMessage).ToList(); // Mapping logic
// Using Anthropic's official .NET client (or a custom HttpClient call)
await _anthropicClient.StreamMessagesAsync(anthropicMessages, request.ToolDefinitions, writer); // Custom method to pipe stream
}
// ... helper methods like MapToAnthropicMessage, WriteSseEvent, ExecuteInternalTool ...
I'm still figuring out the best way to manage the state between multiple MCP requests for a single conversation, especially when tools are involved. Your mileage may vary, but for me, this initial dive into an anthropic mcp tutorial proved that gaining control over the context pipeline is absolutely worth the effort.
If you've tried building an MCP server in .NET, especially with complex tool orchestration or state management, I'd love to hear what challenges you faced and how you solved them.













