DEV Community

Ali Suleyman TOPUZ
Ali Suleyman TOPUZ

Posted on Originally published at topuzas.medium.com on

When (Not) to Use MCP in .NET: A Practical Guide

I built a working MCP server in C#, hit every production gotcha the tutorials skip, and then had to ask myself if any of it was necessary

Three weekends ago I shipped an MCP server. Stdio transport, HTTP transport, DI wired up properly, a couple of real tools behind it. I was proud of it in the specific way you’re proud of infrastructure nobody asked for. Then I plugged it into the same coding agent I already had running, watched it work for about a day, and noticed something: the agent hadn’t touched half the tools I’d built. It just ran curl against the weather API directly when it needed weather. It read the search provider's docs once and wrote its own thin wrapper in about the time it took me to write this sentence. My carefully wrapped, schema-validated, DI-friendly tool server was sitting there mostly unused while the agent did the thing MCP was supposed to save it from doing, by hand, faster than my server could respond.

That’s not the story I expected to write. I went in planning a straightforward “here’s how to build an MCP server in .NET” post, and that post is below, code and all. But I also read Bhargava Koya’s walkthrough on building and consuming tool servers with the C# SDK around the same time, a genuinely solid technical reference, and separately ran into Ronivaldo Passos Sampaio’s argument that MCP starts breaking down at scale once you’re past a couple hundred tools. Between building the thing myself and reading both of those, I ended up somewhere I didn’t plan on: MCP is real infrastructure that solves a real problem, and also a lot of us are reaching for it when a scoped shell command would have done the same job in a tenth of the code.

So this is both things. A working weather-and-search MCP server in C#, with stdio, HTTP, dependency injection, and Semantic Kernel wired in, plus every gotcha that bit me getting it into something I’d trust in production. And then the honest counterargument, because I think it’s the more useful half.

What we’re building

A tool server exposing two capabilities: weather lookups against Open-Meteo (free, no API key) and web search against a self-hosted SearXNG instance (also free, also no API key). I’ll show both stdio and Streamable HTTP transport, because they solve different problems: stdio is for a tool server that lives next to its one client, spawned as a child process. HTTP is for a tool server other things connect to over the network, the shape you want once more than one agent needs the same tools.

The SDK is the official one, modelcontextprotocol/csharp-sdk, maintained with Microsoft, currently on the 2.x line. It ships as five packages and you almost never need all five:

ModelContextProtocol.Core minimal client / low-level server APIs
ModelContextProtocol hosting + dependency injection, the one you want
ModelContextProtocol.AspNetCore HTTP transport for ASP.NET Core
ModelContextProtocol.Extensions.Tasks long-running / async tool invocations
ModelContextProtocol.Extensions.Apps interactive UI extensions, still experimental
Enter fullscreen mode Exit fullscreen mode

For a plain tool server you need ModelContextProtocol, and ModelContextProtocol.AspNetCore if you're doing HTTP.

The stdio server

dotnet new console -n WeatherSearchMcp
cd WeatherSearchMcp
dotnet add package ModelContextProtocol

// Program.cs
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;

var builder = Host.CreateApplicationBuilder(args);
// This line matters more than it looks like it does. More on that below.
builder.Logging.AddConsole(options =>
{
    options.LogToStandardErrorThreshold = LogLevel.Trace;
});
builder.Services.AddHttpClient<WeatherTools>(client =>
{
    client.BaseAddress = new Uri("https://api.open-meteo.com/");
});
builder.Services.AddHttpClient<SearchTools>(client =>
{
    client.BaseAddress = new Uri("http://localhost:8080/"); // your SearXNG instance
});
builder.Services
    .AddMcpServer()
    .WithStdioServerTransport()
    .WithToolsFromAssembly();
await builder.Build().RunAsync();
Enter fullscreen mode Exit fullscreen mode

Tools are plain classes with attributes on them. The SDK resolves tool instances through the DI container, which means constructor injection works exactly like it does in an ASP.NET Core controller:

// WeatherTools.cs
using System.ComponentModel;
using System.Text.Json;
using ModelContextProtocol.Server;

[McpServerToolType]
public class WeatherTools(HttpClient httpClient)
{
    [McpServerTool, Description("Gets the current weather and short forecast for a named city.")]
    public async Task<string> GetForecastAsync(
        [Description("City name, e.g. 'Istanbul' or 'Berlin'")] string city,
        CancellationToken cancellationToken)
    {
        var geoResponse = await httpClient.GetAsync(
            $"https://geocoding-api.open-meteo.com/v1/search?name={Uri.EscapeDataString(city)}&count=1",
            cancellationToken);
        geoResponse.EnsureSuccessStatusCode();
        using var geoDoc = JsonDocument.Parse(await geoResponse.Content.ReadAsStreamAsync(cancellationToken));
        var results = geoDoc.RootElement.GetProperty("results");
        if (results.GetArrayLength() == 0)
            return $"Couldn't find a location named '{city}'.";
        var place = results[0];
        double lat = place.GetProperty("latitude").GetDouble();
        double lon = place.GetProperty("longitude").GetDouble();
        var forecastResponse = await httpClient.GetAsync(
            $"v1/forecast?latitude={lat}&longitude={lon}&current=temperature_2m,weather_code&timezone=auto",
            cancellationToken);
        forecastResponse.EnsureSuccessStatusCode();
        using var forecastDoc = JsonDocument.Parse(await forecastResponse.Content.ReadAsStreamAsync(cancellationToken));
        var current = forecastDoc.RootElement.GetProperty("current");
        double temp = current.GetProperty("temperature_2m").GetDouble();
        return $"Current temperature in {city} is {temp}°C.";
    }
}

// SearchTools.cs
using System.ComponentModel;
using System.Text.Json;
using ModelContextProtocol.Server;

[McpServerToolType]
public class SearchTools(HttpClient httpClient)
{
    [McpServerTool, Description("Searches the web and returns the top results with titles and URLs.")]
    public async Task<string> SearchAsync(
        [Description("The search query")] string query,
        CancellationToken cancellationToken)
    {
        var response = await httpClient.GetAsync(
            $"search?q={Uri.EscapeDataString(query)}&format=json",
            cancellationToken);
        response.EnsureSuccessStatusCode();
        using var doc = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(cancellationToken));
        var items = doc.RootElement.GetProperty("results").EnumerateArray().Take(5);
        var lines = items.Select(r =>
            $"- {r.GetProperty("title").GetString()} ({r.GetProperty("url").GetString()})");
        return string.Join("\n", lines);
    }
}
Enter fullscreen mode Exit fullscreen mode

Run it with dotnet run and it'll sit there waiting on stdin, which looks broken until you remember that's the whole point, it's meant to be spawned by a client, not run interactively.

The SearXNG instance the search tool depends on

# docker-compose.yml
services:
  searxng:
    image: searxng/searxng:latest
    ports:
      - "8080:8080"
    volumes:
      - ./searxng:/etc/searxng
    environment:
      - SEARXNG_BASE_URL=http://localhost:8080/

docker compose up -d
Enter fullscreen mode Exit fullscreen mode

JSON output is off by default on SearXNG, for good reason, it’s a scraping surface if left open. Edit ./searxng/settings.yml after the first run generates it, and add json under search.formats:

search:
  formats:
    - html
    - json
Enter fullscreen mode Exit fullscreen mode

Restart the container and curl 'http://localhost:8080/search?q=test&format=json' should give you a JSON body instead of a 403.

The HTTP server

Same tools, different host. This is where the SDK’s 2.0 line changed the default behavior in a way worth knowing about: HTTP transport is stateless by default now, no session pinning, which means you can put a plain load balancer in front of multiple instances without sticky routing.

// Program.cs (ASP.NET Core version)
using ModelContextProtocol.AspNetCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient<WeatherTools>(client =>
{
    client.BaseAddress = new Uri("https://api.open-meteo.com/");
});
builder.Services.AddHttpClient<SearchTools>(client =>
{
    client.BaseAddress = new Uri("http://searxng:8080/");
});
builder.Services.AddMcpServer()
    .WithHttpTransport(options => options.Stateless = true)
    .WithToolsFromAssembly();
var app = builder.Build();
app.MapMcp("/mcp");
app.Run("http://0.0.0.0:3001");
Enter fullscreen mode Exit fullscreen mode

WeatherTools and SearchTools don't change at all between the two transports. That's the part of the SDK I actually like: the tool code has no idea whether it's being called over stdin or an HTTP POST, which means you can develop against stdio locally, where debugging is simpler, and deploy over HTTP without touching the tools themselves.

Wiring it into Semantic Kernel

Once the server is up, whether stdio or HTTP, the client side is the same handful of calls whether you’re driving it from a raw ModelContextProtocol.Client, Semantic Kernel, or the coding agent you already use. Here's the Semantic Kernel version, since that's what most .NET teams reach for once they're past "call one model":

// dotnet add package Microsoft.SemanticKernel
// dotnet add package ModelContextProtocol
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.OpenAI;
using ModelContextProtocol.Client;
var kernel = Kernel.CreateBuilder()
    .AddOpenAIChatCompletion("gpt-4o-mini", apiKey)
    .Build();
await using IMcpClient mcpClient = await McpClientFactory.CreateAsync(
    new HttpClientTransport(new()
    {
        Endpoint = new Uri("http://localhost:3001/mcp"),
        Name = "WeatherSearchTools"
    }));
var tools = await mcpClient.ListToolsAsync();
kernel.Plugins.AddFromFunctions(
    "WeatherSearchTools",
    tools.Select(tool => tool.AsKernelFunction()));
var settings = new OpenAIPromptExecutionSettings
{
    Temperature = 0,
    FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()
};
var result = await kernel.InvokePromptAsync(
    "What's the weather in Istanbul right now, and find me a recent article about it?",
    new(settings));
Console.WriteLine(result);
Enter fullscreen mode Exit fullscreen mode

AsKernelFunction() does the actual bridging: it takes the MCP tool's JSON Schema and turns it into a KernelFunction the planner can call like any native plugin. From the model's point of view there's no difference between a tool that came from MCP and one you wrote as a plain C# method decorated with [KernelFunction]. That equivalence is the whole value proposition of MCP in one sentence, and exactly the thing the counterargument later in this piece pushes back on.

The gotchas nobody puts in the getting-started doc

stdout is not yours anymore

The single most common way to break a stdio MCP server, and I did this on day one, is to log to standard output. stdio transport uses stdout as the JSON-RPC message channel. Every Console.WriteLine you or a library you depend on writes to stdout gets interleaved into that stream, and the client on the other end tries to parse your log line as a protocol message and fails, usually silently, usually looking like the tool "isn't responding."

The official quickstart actually gives away how real this problem is: its own console logging setup explicitly redirects everything to stderr:

builder.Logging.AddConsole(options =>
{
    options.LogToStandardErrorThreshold = LogLevel.Trace;
});
Enter fullscreen mode Exit fullscreen mode

That’s not a style preference, it’s load-bearing. I found this out because a NuGet package I’d pulled in for HTTP retries logged a single informational line on startup, straight to Console.Out, and my stdio server worked fine locally through the SDK's own test client and then failed the instant I pointed a stricter client at it, because that client actually validated the JSON-RPC framing instead of tolerating a stray line. If you're building on stdio, treat stdout as a protocol pipe, not a debugging surface, full stop, and audit every dependency you add for the same habit.

the HttpClient trap, wearing an MCP costume

This one isn’t new to MCP, it’s the oldest .NET gotcha in the book, but MCP tutorials reintroduce it constantly because the examples are always small enough to hide it. The mistake looks like this:

[McpServerToolType]
public class WeatherTools
{
    private static readonly HttpClient client = new(); // looks fine. isn't.
    [McpServerTool, Description("...")]
    public async Task<string> GetForecastAsync(string city) { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

A single static HttpClient doesn't leak sockets the way new HttpClient() per call does, but it does hold DNS resolutions for the lifetime of the process. If the weather API's IP changes behind a load balancer, your long-running MCP server keeps hammering the old address until you restart it. I watched this happen against a self-hosted service redeployed behind a new pod IP, and the tool quietly timed out for about six minutes before anyone noticed, because nothing crashed, it just got slow.

The other half, instantiating new HttpClient() inside the tool method itself, is worse and more common in copy-pasted examples, because it looks like the "obviously correct, scoped, no shared state" version:

[McpServerTool, Description("...")]
public async Task<string> GetForecastAsync(string city)
{
    using var client = new HttpClient(); // do not do this
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Under a handful of manual test calls this never shows a symptom. Under real load, an MCP HTTP server fielding concurrent tool calls from multiple agent sessions, each new HttpClient() opens its own connection pool and doesn't release the underlying socket back to the OS immediately on disposal, TIME_WAIT holds it. I pushed 200 concurrent calls at a version of WeatherTools built this way and started seeing SocketException: Address already in use around the 140-request mark on my machine. The fix is the one shown above: inject HttpClient through AddHttpClient(), which pools and recycles connections and respects DNS changes on a rotation instead of never. Three lines different from the broken version, and the failure mode is invisible until you're under load, which is exactly why it survives in tutorials.

OAuth 2.1 is required by the spec, not yet automatic in the SDK

The MCP authorization spec mandates OAuth 2.1 for remote HTTP servers, and as of the SDK’s 2.0 line, end-to-end authentication is explicitly called out as the next major focus rather than something WithHttpTransport() hands you for free. In practice that means you're wiring standard ASP.NET Core JWT bearer auth in front of MapMcp() yourself today. Here's the shape of it, with a Protected Resource Metadata endpoint since MCP clients look for one at a well-known path before attempting the OAuth dance:

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = "https://your-auth-server.example.com";
        options.Audience = "mcp-weather-search";
    });

builder.Services.PostConfigure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
{
    options.Events ??= new JwtBearerEvents();
    options.Events.OnChallenge = async context =>
    {
        if (!context.HttpContext.Request.Path.StartsWithSegments("/mcp"))
            return;
        context.HandleResponse();
        context.Response.StatusCode = StatusCodes.Status401Unauthorized;
        var issuer = $"{context.Request.Scheme}://{context.Request.Host}";
        context.Response.Headers.Append("WWW-Authenticate",
            $"Bearer realm=\"mcp\", resource_metadata=\"{issuer}/.well-known/oauth-protected-resource\"");
    };
});
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("McpAccess", policy =>
        policy.RequireAuthenticatedUser().RequireClaim("scope", "mcp:tools"));
});
// ...
app.MapGet("/.well-known/oauth-protected-resource", (HttpContext ctx) =>
{
    var issuer = $"{ctx.Request.Scheme}://{ctx.Request.Host}";
    return Results.Ok(new
    {
        resource = $"{issuer}/mcp",
        authorization_servers = new[] { issuer },
        scopes_supported = new[] { "mcp:tools" }
    });
});
app.MapMcp("/mcp").RequireAuthorization("McpAccess");
Enter fullscreen mode Exit fullscreen mode

If you’re using an external identity provider, Keycloak self-hosted or Entra ID hosted, that provider issues the tokens and you’re only responsible for validating them and publishing the metadata document. The part that trips people up is the metadata endpoint: skip it and spec-compliant clients get a 401 with no path forward, since they never learn where to send a user to authorize.

the scaling numbers, roughly

I didn’t run a formal benchmark suite, I ran bombardier against a bare stateless endpoint on my own laptop for a few minutes, so take the shape of this more seriously than the exact digits. It lines up closely with what Koya found running a similar comparison in his own testing: stateless HTTP with connection reuse comfortably outperforms a session-pinned setup by close to an order of magnitude once concurrency climbs, because session mode pays a handshake cost per client instead of per connection.

SETUP REQUESTS/SEC (approx, 50 concurrent)
----------------------------------------------------------------------------
Stateless HTTP, connection reuse 280-310
Session-pinned HTTP, one session/client 30-40
stdio, one persistent child process N/A (bound by single-process I/O,
                                            not comparable to HTTP concurrency)
stdio, new process spawned per call 8-15 (dominated by ~150-300ms
                                            .NET cold start per spawn)
Enter fullscreen mode Exit fullscreen mode

That last row is the one that actually got me. If you’re tempted to “scale” a stdio server by spawning a fresh process per incoming request, you’re paying full .NET process startup, JIT included, on every call. Stateless HTTP with a warm, long-running server is the only one of these four that scales the way a normal API scales. If your workload is single-agent, single-session, stdio’s simplicity is still fine. The moment more than one thing needs to talk to your tools concurrently, that simplicity is a scaling ceiling wearing a friendly face.

The pivot: does the agent even need this layer

Building the thing changed my mind more than reading about it did. MCP’s actual job is narrow: standardize how a client discovers what a server can do and how it calls it, so you write the server once and any MCP-aware client can use it without custom glue per client. That’s a real, valuable job when the client on the other end is a chat interface with no shell, no filesystem, and no way to write its own integration code on the fly.

A lot of what people are running today isn’t that client anymore. A capable coding agent sitting in a terminal can read Open-Meteo’s docs, write a curl call, parse the JSON, and move on, in less wall-clock time than it takes to round-trip through a tool server's schema negotiation. It can read SearXNG's API docs the same way. It doesn't need a pre-wrapped, pre-validated tool definition to know how to call an HTTP API, because reading API docs and writing an adapter is exactly the kind of task these agents are already good at, arguably the core task they're good at.

Sampaio’s argument, from the SDP piece I mentioned up top, is about a different failure mode than the one I hit, but it rhymes: he describes organizations running 200 MCP servers exposing something close to 5,000 tools, where the model drowns in options before it ever gets to using one well, and where “just add another MCP server” stops being free the way it felt free at tool number ten. My version happened at a much smaller scale, one server, a handful of tools, a single capable agent that didn’t need the wrapper. Different number, same shape: the tool-wrapping layer has a cost, in tokens, in latency, in surface area to secure and version, and that cost has to be paying for something specific, not just existing because it’s available.

So the fair version of the case against MCP isn’t “MCP is bad.” It’s “the tool-wrapping layer is a cost you pay for a specific benefit, and a sufficiently capable client with direct, scoped access to the same underlying systems can get the benefit for less.”

The actual decision framework

After building this both ways, here’s where I actually landed on when the wrapper earns its keep.

USE MCP WHEN SKIP MCP WHEN
----------------------------------------------------------------------------
The client is a weaker or general- The client is a capable coding
purpose model that genuinely can't agent that can already read docs,
write its own API adapter reliably, write a scoped script, and run it,
and needs a pre-validated, schema- faster than a schema round-trip
locked tool definition to call would cost
correctly
You need one server reused by It's one agent, one integration,
multiple, different clients: a chat no reuse story, and the "someone
app, a coding agent, an internal else might need this tool later"
dashboard, all sharing the same justification is speculative
tool without duplicated glue code
You need a real security and The agent already runs inside a
permission boundary around a sandbox or scoped credential set
powerful action, human approval that limits blast radius just as
gates, scoped OAuth tokens, audit well, and MCP would just be
logging on every call, enforced ceremony wrapped around the same
centrally rather than trusted to boundary
agent-side discipline
The capability needs to be portable The integration is a one-off,
across MCP-compatible tools you unlikely to outlive this specific
don't control the internals of project or get reused elsewhere
Enter fullscreen mode Exit fullscreen mode

The security-boundary row is the one I’d weight heaviest, more than reuse, more than weaker-model support. A tool server sitting in front of something genuinely dangerous, a production database with write access, a payments API, infrastructure that can delete things, gives you one place to put approval gates, scoping, and audit logging, enforced structurally instead of trusted to whatever the agent decides to do. An agent with raw shell access to the same systems can be told to be careful. A tool server can be built so it’s not physically possible for it to run DROP TABLE no matter what the model decides. My weather tool was never that. It was a read-only call to a free public API, wrapped in JSON-RPC framing, DI, and a whole protocol layer, in service of a security boundary that didn't need to exist, for a client that could've made the same call itself in three lines of a script.

Running the whole thing against a local model

If you want to actually try this without an OpenAI key, or you just want to keep the loop cheap while you’re iterating on the tool code, everything above works unchanged against Ollama. OllamaApiClient from OllamaSharp implements IChatClient from Microsoft.Extensions.AI.Abstractions, and Semantic Kernel's OpenAI connector happily points at Ollama's OpenAI-compatible endpoint, so nothing about the MCP server or the tool definitions changes at all.

ollama pull llama3.1
ollama serve

// dotnet add package Microsoft.SemanticKernel
// dotnet add package Microsoft.SemanticKernel.Connectors.OpenAI
// dotnet add package ModelContextProtocol
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.OpenAI;
using ModelContextProtocol.Client;
var kernel = Kernel.CreateBuilder()
    .AddOpenAIChatCompletion(
        modelId: "llama3.1",
        endpoint: new Uri("http://localhost:11434/v1"),
        apiKey: "not-needed") // Ollama's OpenAI-compatible endpoint ignores this
    .Build();
await using IMcpClient mcpClient = await McpClientFactory.CreateAsync(
    new HttpClientTransport(new()
    {
        Endpoint = new Uri("http://localhost:3001/mcp"),
        Name = "WeatherSearchTools"
    }));
var tools = await mcpClient.ListToolsAsync();
kernel.Plugins.AddFromFunctions("WeatherSearchTools", tools.Select(t => t.AsKernelFunction()));
var settings = new OpenAIPromptExecutionSettings { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() };
var result = await kernel.InvokePromptAsync("What's the weather in Berlin?", new(settings));
Console.WriteLine(result);
Enter fullscreen mode Exit fullscreen mode

Honest caveat: a 7–8B model is noticeably less reliable at correctly formatted tool calls than a frontier hosted model, so expect some flakiness in which tool it picks or how it formats arguments under multi-step tool use. For validating that your MCP server, DI wiring, and Semantic Kernel plumbing are actually correct before spending a cent on a hosted API, though, it’s the right first stop. Get the plumbing right against Ollama, then swap the connector for the model you intend to run in production.

Where I landed

I still think the server is worth having, for the tools that actually need the boundary MCP gives you. I’ve since deleted the wrapper around the weather tool entirely and let the agent hit Open-Meteo directly, since there was never a permission boundary there worth enforcing, and kept the search tool behind MCP because I wanted one place to rate-limit and log outbound queries against my SearXNG instance rather than trusting every future caller to remember that themselves. That split, not “MCP always” or “MCP never,” is the actual answer I came out of this with. Build the wrapper when you’re buying a weaker model a capability it doesn’t have, or buying yourself a security boundary you can enforce structurally. Skip it when you’re adding ceremony around something a capable agent could already do with a scoped credential and a shell.

Tags: dotnet, mcp, csharp, ai-agents, semantic-kernel, aspnetcore, ollama

Top comments (0)