Creating an MCP Server and Testing It Locally: A C# Walkthrough
Model Context Protocol (MCP) servers are a clean way to expose existing functionality — internal tools, third-party APIs, business logic — to AI assistants like Claude. Wrap your capability in a well-described "tool," register it with an MCP server, and any MCP-compatible client can call it as part of a conversation.
The idea is simple. Getting a working local setup, however, has a handful of non-obvious pitfalls — mostly around how stdio transport works and how it interacts with things like dotnet run, path escaping, and dependency injection. This post walks through building a real MCP server in C# with a small piece of standalone logic — no external calls, no network dependencies — and the specific problems we hit (and fixed) along the way.
What we are building?
The goal: expose a Celsius-to-Fahrenheit temperature converter as an MCP tool, so an AI assistant could call it directly instead of doing the arithmetic itself (or getting it wrong). The logic is deliberately trivial — a single formula, no external API, no authentication — which keeps the focus on the transport-layer issues below rather than on application-specific plumbing.
Setting up the project
Start with a plain console app:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0" />
</ItemGroup>
</Project>
A package-naming gotcha worth flagging up front: the C# MCP SDK ships as three separate NuGet packages, and picking the wrong one is an easy first mistake.
ModelContextProtocol.Core— low-level client/server primitives only, minimal dependenciesModelContextProtocol— the main package, includes hosting and DI extensions likeAddMcpServer()ModelContextProtocol.AspNetCore— adds HTTP transport on top ofModelContextProtocol
If you reference only .Core, you'll get a compile error that looks like a missing using directive:
It isn't a missing using — AddMcpServer() simply doesn't exist in that package. Swap to ModelContextProtocol and it resolves immediately.
The basic server
A minimal stdio-based MCP server looks like this:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
var builder = Host.CreateApplicationBuilder(args);
// Route all logs to stderr — stdout is reserved for the MCP protocol stream
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithToolsFromAssembly();
var app = builder.Build();
await app.RunAsync();
That last comment — routing logs to stderr — is not decoration. It's the single most important line in the file for stdio-based servers, and the reason why is worth understanding before you hit the problem yourself.
Why stdout purity matters
An MCP server over stdio communicates with its client by writing newline-delimited JSON-RPC messages to standard output and reading them from standard input. The client parses every line of stdout as a protocol message. If anything else — a log line, a build warning, a stray Console.WriteLine — lands on stdout, the client's JSON parser chokes on it.
This showed up concretely when testing with dotnet run --project:
dotnet run doesn't just launch your app — it triggers a build check first, and MSBuild's own diagnostic output goes to the same stdout stream your MCP server will later use. The client sees MSBuild's chatter before it sees a single valid protocol message, and the connection is corrupted from the first byte.
dotnet run for the actual test connection. Build first, then point your MCP client directly at the compiled binary.dotnet build
Then configure your client (or the MCP Inspector, see below) with:
- Command:
dotnet - Arguments:
path/to/bin/Debug/net9.0/YourServer.dll
This execs the already-built assembly directly — no MSBuild involved, no risk of stray output hitting the pipe.
Testing with the MCP Inspector
The MCP Inspector is a browser-based tool for manually connecting to a local MCP server and invoking its tools, without needing a full AI client wired up. Launch it with:
npx @modelcontextprotocol/inspector dotnet path/to/YourServer.dll
This pre-fills the Command/Arguments fields automatically, which sidesteps a subtler bug: typing a Windows path with backslashes directly into the Inspector's Arguments field can silently strip the backslashes, turning D:\Projects\Server\bin\... into D:ProjectsServerbin... — a path that resolves to nothing. If you must type the path into the UI rather than passing it via the command line, use forward slashes instead (D:/Projects/Server/bin/...); .NET on Windows accepts them without complaint, and they survive whatever escaping the UI applies.
Once connected, the Inspector lists every tool discovered via WithToolsFromAssembly() and lets you invoke them with arbitrary JSON input — the fastest feedback loop for iterating on a tool before hooking up a real client.
Writing the tool
Tools are just static methods on a class marked [McpServerToolType]. Because this one is pure logic — no I/O, no external dependencies — it can be a plain synchronous method:
[McpServerToolType]
public static class TemperatureConverterTool
{
[McpServerTool, Description("Converts a temperature from degrees Celsius to degrees Fahrenheit.")]
public static string CelsiusToFahrenheit(
[Description("The temperature in degrees Celsius")] double celsius)
{
double fahrenheit = (celsius * 9.0 / 5.0) + 32;
return $"{celsius}°C is {fahrenheit:F2}°F";
}
}
WithToolsFromAssembly() finds this automatically — no manual registration needed. The [Description] attributes on the method and each parameter aren't cosmetic; they're what the AI client reads to decide when and how to call the tool, so they're worth writing carefully even when the underlying logic is this simple.
Keeping the first tool free of network calls and dependency injection is a deliberate choice, not a limitation. It means that when something breaks during local testing, you already know it isn't an HTTP timeout, a missing auth header, or a misconfigured HttpClient — it's almost certainly one of the transport-level issues below. Once the stdio plumbing is verified end-to-end with a trivial tool like this one, adding tools that call out to real services is a much smaller, better-isolated next step.
Debugging generic tool errors
When something goes wrong inside a tool, MCP clients typically surface a generic message like An error occurred invoking 'sample_tool_name' — the framework deliberately swallows exception details rather than leaking stack traces by default. During development, it's worth temporarily wrapping tool bodies in a try/catch that returns the exception directly as the tool's output:
catch (Exception ex)
{
return $"EXCEPTION: {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}";
}
This turns an opaque failure into an immediately actionable stack trace, visible right in the Inspector's response panel — no log-file spelunking required. Strip it out (or replace with proper structured logging) before shipping.
When a failure is genuinely mysterious — like a tool that behaves differently than expected for certain inputs — isolate the problem by adding a throwaway diagnostic tool that echoes back exactly what the server received, before any conversion logic runs:
[McpServerTool, Description("DIAGNOSTIC ONLY")]
public static string DiagnosticEchoInput(double celsius)
{
return $"Received celsius={celsius} (type: {celsius.GetType().Name})";
}
If the echoed value already looks wrong, the problem is upstream — in how the client is serializing arguments or how the parameter is declared — not in your conversion logic. This kind of narrow, no-dependency diagnostic tool is often faster than reasoning about the whole pipeline at once.
The checklist
For anyone building their first stdio MCP server in C#, the failure modes above tend to repeat across projects. Worth checking each of these before you start debugging from scratch:
- Wrong package — reference
ModelContextProtocol, notModelContextProtocol.Core, if you needAddMcpServer() - All logging to stderr —
LogToStandardErrorThreshold = LogLevel.Trace, no exceptions - Never test via
dotnet run— build first, point the client at the compiled DLL - Watch for path mangling in UI tools — prefer forward slashes, or pass paths via the command line instead of typing into a form
- Keep your first tool free of external dependencies where possible, so transport-layer bugs and application bugs don't get tangled together
- Temporarily surface real exceptions from tool methods while debugging, then remove before shipping
