Build Your First MCP Server in C# and .NET
Build a real Model Context Protocol server with the official C# SDK, expose three deterministic read-only tools over stdio, connect with a real MCP client, discover and invoke those tools, and verify the protocol path without needing an LLM or API key.
What You Will Build
This tutorial builds the smallest MCP system that is still worth trusting: a real .NET server, a real MCP client, three read-only tools, deterministic local data, protocol-level discovery, tool invocation, and automated verification.
McpClientDemo
│
│ launches child process
│ MCP over stdio
▼
FirstMcpServer
│
├── get_service_health ───────► ServiceHealthStore
├── get_recent_deployment ────► DeploymentStore
└── get_runbook ──────────────► RunbookStore
│
▼
deterministic JSONReal MCP transport
The client launches the server and communicates through the SDK's StdioClientTransport. This is not a mocked protocol test.
Exactly three tools
The server exposes service health, recent deployment, and runbook tools. All three operate on deterministic local data and perform no writes.
No model required
The complete sample restores, builds, runs 13 deterministic tests, discovers tools, calls them, and verifies an invalid tool request without any AI provider key.
MCP Host, Client, and Server
Before writing code, separate the three roles. MCP uses a client-server architecture, but an AI application usually adds another layer above the client: the host.
MCP host
The AI application or developer tool that coordinates model interaction and owns one or more MCP clients. A host might be an IDE, desktop AI application, or your own agent runtime.
MCP client
A component that maintains a connection to one MCP server. It discovers server capabilities and can list and invoke exposed tools.
MCP server
A program that exposes capabilities—such as tools, resources, or prompts—to connected MCP clients.
Typical AI application
MCP Host
│
├── MCP Client A ─────────► MCP Server A
└── MCP Client B ─────────► MCP Server B
This tutorial
(no LLM / no host)
│
▼
McpClientDemo ── stdio ──► FirstMcpServerThis distinction matters because the sample intentionally does not ask a model to choose a tool. McpClientDemo exercises the protocol directly. That gives us deterministic evidence that the server can be launched, discovered, and invoked before adding any probabilistic model behavior.
server/discover and tool operations such as tools/list and tools/call. The C# SDK handles the protocol exchange for you; the verified server logs show server/discover, then tools/list, then tools/call.Prerequisites and Verified Baseline
This page is tied to an exact tested baseline instead of assuming that every future SDK patch has identical behavior.
| Component | Verified value |
|---|---|
| Target framework | net10.0 |
| .NET SDK | 10.0.401 |
| Solution format | .slnx |
ModelContextProtocol | 2.2.0 |
Microsoft.Extensions.Hosting | 10.0.12 |
xunit.v3.mtp-v2 | 4.0.1 |
| Transport | stdio |
| Deterministic tests | 13 passed, 0 failed, 0 skipped |
| MCP stdio end-to-end | PASS |
| LLM / cloud API calls | None |
{
"sdk": {
"version": "10.0.401",
"rollForward": "latestPatch",
"allowPrerelease": false
},
"test": {
"runner": "Microsoft.Testing.Platform"
}
}
global.json file also selects Microsoft.Testing.Platform as the test runner. Running dotnet test from a directory where that file is not discovered can produce a VSTest/MTP error on newer .NET 10 SDK patches.Project Architecture
The solution uses three projects so each verification layer has a clear job.
FirstMcpServer.slnx │ ├── src/ │ ├── FirstMcpServer/ │ │ ├── Program.cs │ │ ├── Data/ │ │ ├── Models/ │ │ ├── Stores/ │ │ └── Tools/ │ │ │ └── McpClientDemo/ │ └── Program.cs │ ├── tests/ │ └── FirstMcpServer.Tests/ │ ├── docs/ ├── scripts/verify.ps1 └── verified-environment.json
FirstMcpServer owns server registration and trusted tool implementations. McpClientDemo launches that server through stdio and verifies the real protocol surface. The test project checks stores and tool behavior without needing to start an MCP transport.
Create the .NET 10 Project
The server uses the main ModelContextProtocol package because this is a hosted stdio server with dependency injection and attribute-based tool discovery. The project also references Microsoft.Extensions.Hosting.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.12" />
<PackageReference Include="ModelContextProtocol" Version="2.2.0" />
</ItemGroup>
<ItemGroup>
<None Update="Data\*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
The deterministic JSON files are copied to the output directory. That lets the server load them relative to AppContext.BaseDirectory, which works the same way when the client launches the built server DLL.
ModelContextProtocol as the right starting point for most clients and stdio servers that want hosting, dependency injection, and attribute-based discovery. HTTP-hosted servers use the ASP.NET Core package instead.Configure the stdio MCP Server
The complete server bootstrap is small. Register the stores with DI, add MCP, choose stdio transport, and discover tool types from the assembly.
using FirstMcpServer.Stores;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
var builder = Host.CreateApplicationBuilder(args);
// stdio is the MCP protocol channel. Keep ordinary diagnostics off stdout.
builder.Logging.ClearProviders();
builder.Logging.AddConsole(options =>
{
options.LogToStandardErrorThreshold = LogLevel.Trace;
});
builder.Services.AddSingleton<ServiceHealthStore>();
builder.Services.AddSingleton<DeploymentStore>();
builder.Services.AddSingleton<RunbookStore>();
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithToolsFromAssembly();
await builder.Build().RunAsync();
AddMcpServer() adds the MCP server services and builder.WithStdioServerTransport() uses standard input/output as the transport channel for a local child process.WithToolsFromAssembly() finds classes marked with [McpServerToolType] and their MCP tool methods.Add Deterministic Data
The protocol is the subject of this tutorial, so the data source should not introduce another moving part. The sample uses fixed JSON records for service health, deployments, and runbooks.
[
{
"evidenceId": "E-101",
"service": "checkout-api",
"observedAt": "2026-09-24T14:12:00Z",
"status": "degraded",
"errorRate": 18.7,
"errorSpikeStarted": "2026-09-24T14:09:00Z",
"databasePoolUtilization": 96.0
},
{
"evidenceId": "E-102",
"service": "catalog-api",
"observedAt": "2026-09-24T14:12:00Z",
"status": "healthy",
"errorRate": 0.2,
"errorSpikeStarted": "",
"databasePoolUtilization": 41.0
}
]
The ServiceHealthStore loads those records and returns the newest matching service. Equivalent stores handle deployments and runbooks. Tests can also create stores from in-memory records through FromRecords(...).
public ServiceHealthRecord? Get(string serviceName)
{
string normalized = Normalize(serviceName);
return _records
.Where(x => string.Equals(
x.Service,
normalized,
StringComparison.OrdinalIgnoreCase))
.OrderByDescending(
x => x.ObservedAt,
StringComparer.Ordinal)
.FirstOrDefault();
}
Expose Read-Only MCP Tools
The C# SDK's attribute-based approach keeps each tool close to an ordinary C# method. The type is marked with [McpServerToolType], and the callable method uses [McpServerTool].
using FirstMcpServer.Stores;
using ModelContextProtocol.Server;
using System.ComponentModel;
namespace FirstMcpServer.Tools;
[McpServerToolType]
public static class ServiceHealthTools
{
[McpServerTool(
Name = "get_service_health",
ReadOnly = true,
Destructive = false,
Idempotent = true,
OpenWorld = false)]
[Description("Returns deterministic synthetic service-health evidence for a known service.")]
public static string GetServiceHealth(
ServiceHealthStore store,
[Description("Service name, for example checkout-api.")] string serviceName)
{
string normalized = serviceName?.Trim() ?? string.Empty;
var record = store.Get(normalized);
return record is null
? ToolJson.Serialize(new { found = false, service = normalized })
: ToolJson.Serialize(record);
}
}
The first parameter, ServiceHealthStore store, is supplied from dependency injection. The serviceName parameter is the user-facing tool argument. The SDK can use [Description] metadata when generating the tool schema exposed to clients.
| Tool | Backing store | Purpose |
|---|---|---|
get_service_health | ServiceHealthStore | Returns the newest deterministic health record for a known service. |
get_recent_deployment | DeploymentStore | Returns the most recent deterministic deployment record. |
get_runbook | RunbookStore | Returns investigation guidance and an explicit caution that guidance does not establish root cause. |
{"found":false,"service":"unknown-api"} rather than inventing data.Understand the Tool Annotations
Each sample tool supplies four behavioral annotations. They help clients understand what kind of operation is being exposed.
| Annotation | Sample value | Meaning here |
|---|---|---|
ReadOnly | true | The tool does not modify its environment. |
Destructive | false | The sample does not expose destructive behavior. |
Idempotent | true | Repeated calls with the same arguments do not create additional side effects. |
OpenWorld | false | The tool works against the sample's closed deterministic data set rather than arbitrary external entities. |
Build a Real MCP Client
The client demo receives the path to the built server DLL, launches it with dotnet, and creates an McpClient over StdioClientTransport.
var safeEnvironment = StdioClientTransportOptions.GetDefaultEnvironmentVariables();
var transport = new StdioClientTransport(new StdioClientTransportOptions
{
Name = "DOTNET-GUIDE-First-MCP-Server",
Command = "dotnet",
Arguments = [serverDll],
WorkingDirectory = Path.GetDirectoryName(serverDll),
InheritEnvironmentVariables = false,
EnvironmentVariables = safeEnvironment,
ShutdownTimeout = TimeSpan.FromSeconds(10),
StandardErrorLines = line => Console.WriteLine($"SERVER_STDERR|{line}")
});
await using McpClient client = await McpClient.CreateAsync(transport);
Two defensive details are worth keeping. First, InheritEnvironmentVariables = false avoids passing every parent-process environment variable to the child server. Second, server stderr is captured separately so diagnostics cannot corrupt the stdout protocol channel.
McpClientDemo
│
├── starts: dotnet FirstMcpServer.dll
│
├── writes MCP messages ─────► server stdin
│
├── reads MCP messages ◄───── server stdout
│
└── reads diagnostics ◄────── server stderrDiscover the Server Tools
After the client is connected, tool discovery is a protocol operation. The demo asks the server for its tool list and then verifies the exact names.
IList<McpClientTool> tools = await client.ListToolsAsync();
Console.WriteLine($"RUNTIME|ToolCount|{tools.Count}");
foreach (var tool in tools.OrderBy(t => t.Name, StringComparer.Ordinal))
{
Console.WriteLine(
$"TOOL|{tool.Name}|{tool.GetType().FullName}|{tool.Description}");
}
string[] expectedTools =
[
"get_service_health",
"get_recent_deployment",
"get_runbook"
];
The verified run returned exactly:
get_recent_deployment get_runbook get_service_health Tool count: 3
Invoke Tools and Verify Errors
The client then calls each tool through MCP. The health check demonstrates the pattern: send the tool name plus an argument dictionary, read the returned text content, and assert the known deterministic record.
CallToolResult healthResult = await client.CallToolAsync(
"get_service_health",
new Dictionary<string, object?>
{
["serviceName"] = "checkout-api"
},
cancellationToken: CancellationToken.None);
string healthText = TextOf(healthResult);
Check(
healthResult.IsError is not true &&
healthText.Contains(""evidenceId":"E-101"", StringComparison.Ordinal) &&
healthText.Contains(""status":"degraded"", StringComparison.Ordinal) &&
healthText.Contains(""errorRate":18.7", StringComparison.Ordinal),
"SERVICE_HEALTH_CALL",
healthText);
The sample repeats the same pattern for get_recent_deployment and get_runbook. Finally, it intentionally asks for a tool that does not exist.
bool unknownRejected = false;
string unknownDetail = string.Empty;
try
{
CallToolResult unknown = await client.CallToolAsync(
"does_not_exist",
new Dictionary<string, object?>(),
cancellationToken: CancellationToken.None);
if (unknown.IsError is true)
{
unknownRejected = true;
unknownDetail = TextOf(unknown);
}
}
catch (Exception ex)
{
unknownRejected = true;
unknownDetail = $"{ex.GetType().Name}: {ex.Message}";
}
Check(unknownRejected, "UNKNOWN_TOOL_REJECTION", unknownDetail);
McpProtocolException for does_not_exist. That log is not a failed verification. The client must observe the rejection and then continue to FINAL|PASS.Build, Test, and Run the MCP Sample
Run these commands from the sample root so the pinned SDK and Microsoft Testing Platform selection in global.json are applied.
dotnet restore .\FirstMcpServer.slnx
dotnet build .\FirstMcpServer.slnx --configuration Release --no-restore
dotnet test .\tests\FirstMcpServer.Tests\FirstMcpServer.Tests.csproj --configuration Release --no-build
Now run the real stdio client/server path:
dotnet run `
--project .\src\McpClientDemo\McpClientDemo.csproj `
--configuration Release `
--no-build `
-- `
.\src\FirstMcpServer\bin\Release\net10.0\FirstMcpServer.dll
For the complete repository verification—including package graph capture and evidence files—you can also run:
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\verify.ps1
10.0.401, runs from the sample directory so global.json is honored, then executes restore, Release build, 13 deterministic tests, and the real MCP stdio E2E path.What the Verified Run Proved
The useful result is not just “the process exited successfully.” The verification records separate compile, deterministic, protocol, and CI evidence.
| Evidence level | What was proved |
|---|---|
| Compile verified | The pinned .NET 10 projects and package API surface compile in Release. |
| Deterministically tested | The stores and tool behavior pass 13/13 automated tests without an external service. |
| MCP stdio E2E verified | A real client launches a real server, discovers exactly three tools, invokes all three, and observes the expected results over stdio. |
| CI verified | The published GitHub workflow reproduces the deterministic build/test/MCP path on its runner with the pinned SDK. |
RUNTIME|TransportType|ModelContextProtocol.Client.StdioClientTransport
RUNTIME|ClientType|ModelContextProtocol.Client.McpClientImpl
RUNTIME|ToolCount|3
TOOL|get_recent_deployment|ModelContextProtocol.Client.McpClientTool|...
TOOL|get_runbook|ModelContextProtocol.Client.McpClientTool|...
TOOL|get_service_health|ModelContextProtocol.Client.McpClientTool|...
VERIFY|TOOL_DISCOVERY|PASS|get_recent_deployment,get_runbook,get_service_health
VERIFY|AIFUNCTION_ASSIGNABILITY|PASS|McpClientTool derives from AIFunction
VERIFY|SERVICE_HEALTH_CALL|PASS|{"evidenceId":"E-101",...}
VERIFY|DEPLOYMENT_CALL|PASS|{"evidenceId":"E-201",...}
VERIFY|RUNBOOK_CALL|PASS|{"runbookId":"RB-CHECKOUT-03",...}
VERIFY|UNKNOWN_TOOL_REJECTION|PASS|McpProtocolException: Request failed (remote): Unknown tool: 'does_not_exist'
VERIFY|STDIO_END_TO_END|PASS|Client launched server, discovered exactly three tools, and invoked all three over MCP stdio
FINAL|PASS
Why stdout and stderr Matter
Stdio is simple, but it has one strict operational rule: stdout is part of the protocol transport. If ordinary application logs are written into the same stream, the client can receive bytes that are not MCP protocol messages.
stdout └── MCP protocol messages only stderr └── application / hosting / MCP diagnostics
That is why the server clears the default logging providers and re-adds console logging with LogToStandardErrorThreshold = LogLevel.Trace. The client then collects those lines through StandardErrorLines.
McpClientTool and AIFunction: the Bridge to Later Agent Integration
The verified client records one useful extension point:
Check(
typeof(AIFunction).IsAssignableFrom(typeof(McpClientTool)),
"AIFUNCTION_ASSIGNABILITY",
"McpClientTool derives from AIFunction");
This matters because the discovered MCP tools can participate in APIs built around Microsoft.Extensions.AI.AIFunction. But this tutorial deliberately stops at the protocol boundary.
THIS TUTORIAL
MCP server ──► MCP client ──► McpClientTool
│
└── is an AIFunction
│
▼
LATER TUTORIAL AI agent / IChatClient
model-driven selectionProduction and Remote MCP Considerations
Stdio is a good teaching transport and a common local integration model, but production requirements may push you toward remote hosting and a broader security design.
Transport choice
Keep stdio for local child-process integrations. Use Streamable HTTP when the MCP server must be reached remotely over a network.
Authorization
Read-only does not mean public. Real operational data may still be sensitive, so authenticate clients and authorize access at the server boundary.
Input validation
Tool schemas and descriptions improve discoverability, but server code must still validate identifiers, ranges, permissions, and business rules.
- Replace local JSON with adapters. Databases, monitoring APIs, deployment systems, or ticketing services can sit behind the same tool boundary.
- Use explicit tool registration when appropriate. Assembly scanning is convenient here; Native AOT or tighter public-surface control may favor explicit registration.
- Treat tool annotations as metadata. Do not use
ReadOnly=trueas your authorization system. - Audit tool calls. Record who invoked a tool, which arguments were used, what source was accessed, and whether a side effect occurred.
- Separate protocol tests from model evaluations. MCP can be deterministic while the model that later chooses tools remains probabilistic.
- Add remote concerns intentionally. HTTP hosting, OAuth, rate limits, multi-tenancy, retries, and deployment belong in a dedicated remote-server design rather than being hidden inside a first tutorial.
Common Problems and What They Mean
VSTest target is no longer supported by Microsoft.Testing.Platform
Run the test command from dotnet-ai/mcp-server-csharp-dotnet so the CLI discovers the sample's global.json, and use the verified SDK baseline 10.0.401. The GitHub workflow does both.
The client hangs or fails to parse messages
Check stdout first. A stdio server must reserve stdout for MCP protocol traffic. Send ordinary diagnostics to stderr.
The client discovers zero tools
Confirm the tool classes have [McpServerToolType], methods have [McpServerTool], and the server calls WithToolsFromAssembly().
A DI-backed tool parameter appears to be missing
Register the store with the host's service collection. Parameters that come from DI are not ordinary client-supplied MCP arguments.
The does_not_exist call prints an exception
That is intentional in this sample. The negative test proves unknown tool names are rejected by the server. Verification still succeeds when the client records UNKNOWN_TOOL_REJECTION|PASS.
The sample works, but there is no AI response
Correct. This tutorial tests MCP directly. It does not include a host application, LLM, or model-driven tool selection. That is the next integration layer, not a missing feature in this sample.
Frequently Asked Questions
What is the Model Context Protocol (MCP)?
MCP is an open protocol for connecting AI applications to external tools and data sources through a standardized client-server interface.
Does this MCP server require an LLM or API key?
No. The companion sample uses a real MCP client and server over stdio, but it does not call an LLM or cloud model provider. Build, tests, tool discovery, and tool invocation are deterministic.
Why does this tutorial use stdio instead of HTTP?
Stdio keeps the first sample local and focused on protocol behavior. The client launches the server as a child process and communicates over standard input and output without networking, authentication, or hosting infrastructure.
How many MCP tools does the sample expose?
Exactly three: get_service_health, get_recent_deployment, and get_runbook. All three are read-only and use deterministic local JSON data.
What do ReadOnly, Destructive, Idempotent, and OpenWorld mean on an MCP tool?
They are tool-behavior annotations communicated to clients. In this sample the tools are marked read-only, non-destructive, idempotent, and closed-world. These annotations are useful metadata, not a substitute for server-side authorization or validation.
Why are server diagnostics sent to stderr?
With stdio transport, stdout is the protocol channel. Sending normal application logging to stdout can interfere with protocol messages, so the sample directs diagnostics to stderr.
Can these MCP tools be reused with an AI agent later?
Yes. The verified client records that McpClientTool derives from Microsoft.Extensions.AI.AIFunction. This tutorial treats that as an extension point only; it does not add Agent Framework or LLM-driven tool selection.
Official MCP and Microsoft References
The protocol concepts and C# SDK APIs discussed here are grounded in the official MCP documentation, the official C# SDK, and Microsoft's .NET MCP guidance.
Verified dotnet-guide.com Companion Sample
The complete implementation for this tutorial is published in the dotnet-guide.com tutorial repository. It includes the three-project solution, deterministic JSON data, MCP server and client, 13 automated tests, protocol evidence, package graph, and verification scripts.