🔌 Protocol first, model later

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.

Real 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.

Verified companion sample: the exact source used by this tutorial is published at dotnet-ai/mcp-server-csharp-dotnet. The merged GitHub implementation is the technical source of truth for the code shown here.

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.

This 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.

Protocol note: current MCP documentation describes discovery through 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.

ComponentVerified value
Target frameworknet10.0
.NET SDK10.0.401
Solution format.slnx
ModelContextProtocol2.2.0
Microsoft.Extensions.Hosting10.0.12
xunit.v3.mtp-v24.0.1
Transportstdio
Deterministic tests13 passed, 0 failed, 0 skipped
MCP stdio end-to-endPASS
LLM / cloud API callsNone
global.json
{
  "sdk": {
    "version": "10.0.401",
    "rollForward": "latestPatch",
    "allowPrerelease": false
  },
  "test": {
    "runner": "Microsoft.Testing.Platform"
  }
}
Run commands from the sample directory. The 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 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.

1

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.

src/FirstMcpServer/FirstMcpServer.csproj
<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.

Package choice: the official C# SDK describes 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.
2

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.

src/FirstMcpServer/Program.cs
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();
1
AddMcpServer() adds the MCP server services and builder.
2
WithStdioServerTransport() uses standard input/output as the transport channel for a local child process.
3
WithToolsFromAssembly() finds classes marked with [McpServerToolType] and their MCP tool methods.
4
DI stores are registered once and can be supplied to tool methods by the SDK instead of appearing as user-facing tool arguments.
No web server is hiding here. There is no Kestrel endpoint, controller, Minimal API route, OAuth handler, or HTTP listener. The client starts this executable and speaks MCP through the process streams.
3

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.

src/FirstMcpServer/Data/services.json
[
  {
    "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(...).

Stores/ServiceHealthStore.cs — lookup core
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();
}
Why deterministic JSON? The protocol test should fail because MCP is broken, not because a monitoring API is unavailable. Later you can replace the stores with authenticated production adapters while keeping the tool boundary.
4

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].

Tools/ServiceHealthTools.cs
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.

ToolBacking storePurpose
get_service_healthServiceHealthStoreReturns the newest deterministic health record for a known service.
get_recent_deploymentDeploymentStoreReturns the most recent deterministic deployment record.
get_runbookRunbookStoreReturns investigation guidance and an explicit caution that guidance does not establish root cause.
Unknown services stay explicit. When a store returns no matching record, the tool returns compact JSON such as {"found":false,"service":"unknown-api"} rather than inventing data.
5

Understand the Tool Annotations

Each sample tool supplies four behavioral annotations. They help clients understand what kind of operation is being exposed.

AnnotationSample valueMeaning here
ReadOnlytrueThe tool does not modify its environment.
DestructivefalseThe sample does not expose destructive behavior.
IdempotenttrueRepeated calls with the same arguments do not create additional side effects.
OpenWorldfalseThe tool works against the sample's closed deterministic data set rather than arbitrary external entities.
Annotations are hints, not security controls. Do not rely on a client to enforce authorization, validation, or approval because a tool advertises itself as safe. Production tools still need server-side security and input validation.
6

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.

src/McpClientDemo/Program.cs — transport and client
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.

7

Discover 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.

McpClientDemo — tool discovery
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:

Why exact discovery matters: the test does not merely prove that “some MCP server started.” It proves that the intended public tool surface is the one clients actually see over the protocol.
8

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.

McpClientDemo — call get_service_health
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.

McpClientDemo — unknown-tool rejection
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);
The server error is expected. During this negative test the server logs an McpProtocolException for does_not_exist. That log is not a failed verification. The client must observe the rejection and then continue to FINAL|PASS.
9

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.

PowerShell — restore, build, test
dotnet restore .\FirstMcpServer.slnx
dotnet build .\FirstMcpServer.slnx --configuration Release --no-restore
dotnet test .\tests\FirstMcpServer.Tests\FirstMcpServer.Tests.csproj --configuration Release --no-build
Verified result: Release build completed with 0 warnings and 0 errors. The deterministic suite reported 13 total, 13 passed, 0 failed, 0 skipped.

Now run the real stdio client/server path:

PowerShell — real MCP stdio demo
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 — complete verifier
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\verify.ps1
CI uses the same boundary. The merged GitHub workflow pins SDK 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 levelWhat was proved
Compile verifiedThe pinned .NET 10 projects and package API surface compile in Release.
Deterministically testedThe stores and tool behavior pass 13/13 automated tests without an external service.
MCP stdio E2E verifiedA real client launches a real server, discovers exactly three tools, invokes all three, and observes the expected results over stdio.
CI verifiedThe published GitHub workflow reproduces the deterministic build/test/MCP path on its runner with the pinned SDK.
Curated verification output
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.

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.

Troubleshooting rule: when a stdio MCP process appears to “randomly” fail parsing, inspect anything writing to stdout before changing protocol code.

McpClientTool and AIFunction: the Bridge to Later Agent Integration

The verified client records one useful extension point:

McpClientDemo — compatibility check
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.

Do not overstate the result. This sample proves type compatibility and MCP behavior. It does not prove LLM-driven tool selection, Agent Framework behavior, or model safety.

Production 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=true as 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.
Deliberately excluded from MCP #1: Streamable HTTP, OAuth/authentication/authorization, resources, prompts, sampling, elicitation, MCP Tasks, write-capable tools, databases, RAG, Agent Framework, multi-agent workflows, cloud deployment, and production-readiness claims.

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.

  1. Microsoft Learn: Get started with .NET AI and the Model Context Protocol
  2. Model Context Protocol: Architecture overview
  3. Official Model Context Protocol C# SDK
  4. MCP C# SDK: Getting started
  5. MCP C# SDK: Tools
  6. MCP C# SDK: Transports

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.

Verified scope: Release build passed with 0 warnings and 0 errors; 13/13 deterministic tests passed; the real stdio client discovered exactly three tools; all three calls passed; unknown-tool rejection passed; and the GitHub CI job reproduced the MCP verification. No LLM or cloud API was required.
Independent educational sample: dotnet-guide.com is an independent educational publisher. This tutorial and companion implementation are not official Microsoft or Model Context Protocol specification material.