This is the second part of Building MCP Servers in .NET for AI Agents. The topics we’ll cover are:
- What is the Model Context Protocol? An Introduction for .NET Developers
- Building Your First MCP Server in C# (This Post)
- MCP Tools, Resources and Prompts in .NET
- Wiring MCP Tools to Azure SQL and External APIs
- Hosting an MCP Server in ASP.NET Core with Streamable HTTP
- Building an MCP Client and AI Agent in .NET
- Securing an MCP Server with Microsoft Entra ID
- Deploying an MCP Server to Azure Container Apps
- Connecting an MCP Server to Microsoft Foundry Agent Service
- Observability, Cost Control and MCP Security
The source code for this tutorial is available on GitHub.
Building Your First MCP Server in C#
In Part 1 we connected VS Code to somebody else’s MCP server and watched a model call a tool. Now we write our own.
By the end of this post you’ll have a .NET console application exposing two tools over stdio, and GitHub Copilot answering questions about expense reports by calling them. The protocol work is handled by the SDK, which leaves you writing ordinary C# methods.
Note: this post uses .NET 10 and version 2.2.0 of the ModelContextProtocol package. The jump from version 1 to version 2 was a breaking change, so code you find elsewhere may not compile against 2.x. I’ll pin every version in the csproj so you can see exactly what I ran.
What we’re building
The sample is BitOfTech Expenses, and in this part it does two things:
search_expense_reportsfinds reports, optionally filtered by employee name and status.get_expense_reportreturns a single report with its line items.
The data is a hard coded list in memory. No database, no configuration, nothing to install. Part 4 swaps it for Azure SQL, and I’ve written the classes so that swap doesn’t change their shape.
How a stdio server actually runs
With the stdio transport, your server is not a service that you start and leave running. VS Code launches your executable as a child process. It writes JSON-RPC messages to that process’s standard input, and reads replies from its standard output. When VS Code shuts down, your process is killed.
That has one consequence you have to respect.
Important note: stdout belongs to the protocol. A single Console.WriteLine in your tool code, or a stray log line, lands in the middle of the JSON-RPC stream and the client drops the connection. The error you get is unhelpful. Everything you want to print has to go to stderr instead, and the first thing we’ll do in Program.cs is make sure logging does exactly that.
Building the server
Step 1: Create the solution and the project
|
1 2 3 4 5 6 |
mkdir bitoftech-expenses cd bitoftech-expenses dotnet new sln -n BitOfTech.Expenses dotnet new console -o src/BitOfTech.Expenses.Mcp -f net10.0 dotnet sln add src/BitOfTech.Expenses.Mcp/BitOfTech.Expenses.Mcp.csproj |
A console application is the right project type here. There’s no web server and no HTTP, just a process reading and writing streams.
Note: on .NET 10, dotnet new sln creates BitOfTech.Expenses.slnx rather than the old .sln format. It’s the newer XML solution format and it works in Visual Studio 2022 17.14 and later, and with the C# Dev Kit in VS Code. If your tooling doesn’t like it, dotnet new sln --format sln gives you the classic file.
We add the solution now even though there’s only one project in it. Part 6 adds the agent, and at that point it’s a one line change.
Step 2: Add the packages
|
1 2 3 4 |
cd src/BitOfTech.Expenses.Mcp dotnet add package ModelContextProtocol --version 2.2.0 dotnet add package Microsoft.Extensions.Hosting --version 10.0.12 |
Let’s review what each one is for:
ModelContextProtocolis the main SDK package. It gives you the hosting extensions, the stdio transport, and the attributes that turn a C# method into a tool. There’s also aModelContextProtocol.Corepackage with fewer dependencies, and aModelContextProtocol.AspNetCorepackage we’ll need in Part 5. This one is the right starting point.Microsoft.Extensions.Hostinggives us the generic host, which brings dependency injection, configuration and logging. The SDK is built to sit on top of it.
Your csproj should now look like this:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
<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> </Project> |
Step 3: Write the models
Three small classes. Create a Models folder and add Employee.cs:
|
1 2 3 4 5 6 7 8 9 10 |
namespace BitOfTech.Expenses.Mcp.Models; public class Employee { public int Id { get; set; } public string Name { get; set; } = string.Empty; public string Email { get; set; } = string.Empty; } |
Then ExpenseLine.cs:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
namespace BitOfTech.Expenses.Mcp.Models; public class ExpenseLine { public int Id { get; set; } public int ExpenseReportId { get; set; } public string Description { get; set; } = string.Empty; public string Category { get; set; } = string.Empty; public decimal Amount { get; set; } public DateOnly IncurredOn { get; set; } } |
And ExpenseReport.cs:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
namespace BitOfTech.Expenses.Mcp.Models; public enum ExpenseReportStatus { Draft, Submitted, Approved, Rejected } public class ExpenseReport { public int Id { get; set; } public int EmployeeId { get; set; } public string Title { get; set; } = string.Empty; public ExpenseReportStatus Status { get; set; } /// <summary>ISO 4217 code the report was filed in, for example EUR.</summary> public string Currency { get; set; } = string.Empty; public decimal TotalAmount { get; set; } public DateOnly? SubmittedOn { get; set; } public List<ExpenseLine> Lines { get; set; } = []; } |
Two choices in there are deliberate:
Statusis an enum rather than a string. That turns out to matter a lot once the model gets hold of it, and I’ll show you why in Step 8.- These are plain classes with settable properties, which is what EF Core wants. Part 4 annotates them and maps them to tables without changing their shape.
SubmittedOn is nullable because a draft hasn’t been submitted yet.
Step 4: Seed some data
Create a Data folder and add ExpenseStore.cs. I’m showing the first report in full and trimming the rest, the complete file is in the repo:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
using BitOfTech.Expenses.Mcp.Models; namespace BitOfTech.Expenses.Mcp.Data; // Part 4 replaces this with an EF Core DbContext. The property names match the // DbSet names it will expose, so the LINQ in the tools does not have to change. public class ExpenseStore { public IReadOnlyList<Employee> Employees { get; } = [ new() { Id = 1, Name = "Sara Haddad", Email = "sara.haddad@bitoftech.net" }, new() { Id = 2, Name = "Omar Nasser", Email = "omar.nasser@bitoftech.net" }, new() { Id = 3, Name = "Lina Farah", Email = "lina.farah@bitoftech.net" } ]; public IReadOnlyList<ExpenseReport> Reports { get; } = [ new() { Id = 1001, EmployeeId = 1, Title = "Berlin customer workshop", Status = ExpenseReportStatus.Submitted, Currency = "EUR", TotalAmount = 842.50m, SubmittedOn = new DateOnly(2026, 9, 2), Lines = [ new() { Id = 1, ExpenseReportId = 1001, Description = "Return flight LHR to BER", Category = "Travel", Amount = 410.00m, IncurredOn = new DateOnly(2026, 8, 31) }, new() { Id = 2, ExpenseReportId = 1001, Description = "Hotel, two nights", Category = "Accommodation", Amount = 312.50m, IncurredOn = new DateOnly(2026, 8, 31) }, new() { Id = 3, ExpenseReportId = 1001, Description = "Airport transfers", Category = "Travel", Amount = 120.00m, IncurredOn = new DateOnly(2026, 9, 1) } ] }, // 1002 Azure certification exam, Sara, Approved, USD // 1003 London team offsite, Omar, Submitted, GBP // 1004 Mobile and broadband, Lina, Rejected, GBP // 1005 Client dinner Amsterdam, Omar, Draft, EUR ]; } |
Five reports across three employees, in three currencies, covering every status. The mix isn’t accidental. The currencies give Part 4 something real to convert, and having an approved report and a rejected one matters for a problem we’ll hit in Step 9.
There’s no interface here and no repository. In Part 4 this class is replaced by a DbContext and the constructor parameter in the next step changes. That’s the whole migration, and I’d rather show you that swap than hide it behind an abstraction we don’t need yet.
Step 5: Write the tools
This is the only file with anything MCP specific in it. Create a Tools folder and add ExpenseTools.cs:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
using System.ComponentModel; using BitOfTech.Expenses.Mcp.Data; using BitOfTech.Expenses.Mcp.Models; using ModelContextProtocol; using ModelContextProtocol.Server; namespace BitOfTech.Expenses.Mcp.Tools; [McpServerToolType] public class ExpenseTools(ExpenseStore store) { [McpServerTool(Name = "search_expense_reports")] [Description("Searches expense reports by employee name and status. Returns every matching report with its line items.")] public IReadOnlyList<ExpenseReport> SearchExpenseReports( [Description("Part of an employee name, matched case insensitively. Omit to search across all employees.")] string? employeeName = null, [Description("Only return reports in this status. Omit to return reports in any status.")] ExpenseReportStatus? status = null) { IEnumerable<ExpenseReport> results = store.Reports; if (!string.IsNullOrWhiteSpace(employeeName)) { var matchingIds = store.Employees .Where(e => e.Name.Contains(employeeName, StringComparison.OrdinalIgnoreCase)) .Select(e => e.Id) .ToHashSet(); results = results.Where(r => matchingIds.Contains(r.EmployeeId)); } if (status is not null) { results = results.Where(r => r.Status == status); } return results.OrderByDescending(r => r.SubmittedOn).ToList(); } [McpServerTool(Name = "get_expense_report")] [Description("Gets a single expense report with its line items, by report id.")] public ExpenseReport GetExpenseReport( [Description("The expense report id, for example 1001.")] int reportId) { return store.Reports.FirstOrDefault(r => r.Id == reportId) ?? throw new McpException($"No expense report found with id {reportId}."); } } |
What we have done here is the following:
[McpServerToolType]marks the class as containing tools. The SDK scans for this attribute at startup.[McpServerTool]marks a method as a tool. I’ve setNameexplicitly because MCP tool names are conventionally snake_case, and I’d rather be sure than rely on a naming convention that might change.[Description]on the method and on every parameter is not documentation for your colleagues. It’s the only thing the model reads. If you get it wrong, the model calls your tool wrongly, which is the subject of Step 9 and most of Part 3.- The constructor takes
ExpenseStore. Tool classes are resolved from the DI container, so anything you’ve registered can be injected. That’s how Part 4 gets aDbContextin here. - Returning a C# object is enough. The SDK serialises it to JSON for you.
McpExceptionis the one exception type whose message reaches the caller. If you throw anything else, the client receives a generic “An error occurred invoking …” string with your message stripped out. That’s a deliberate decision by the SDK so you don’t leak internals, and it means you have to opt in when you want the model to understand what went wrong.
Step 6: Wire it up
Replace the contents of Program.cs:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
using BitOfTech.Expenses.Mcp.Data; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; var builder = Host.CreateApplicationBuilder(args); // stdout carries the MCP protocol itself, so every log line must go to stderr. builder.Logging.AddConsole(options => options.LogToStandardErrorThreshold = LogLevel.Trace); builder.Services.AddSingleton<ExpenseStore>(); builder.Services .AddMcpServer() .WithStdioServerTransport() .WithToolsFromAssembly(); await builder.Build().RunAsync(); |
That’s the entire server. Let’s review it line by line:
LogToStandardErrorThreshold = LogLevel.Tracesends every log, at every level, to stderr. This is the line that keeps stdout clean for the protocol. Leave it out and your server will appear to start and then fail to connect.AddSingleton<ExpenseStore>()is ordinary DI registration. In Part 4 this becomesAddDbContext<ExpensesDbContext>().AddMcpServer()registers the server itself.WithStdioServerTransport()says to talk over standard input and output. Part 5 replaces this one call withWithHttpTransport().WithToolsFromAssembly()scans the assembly for[McpServerToolType]classes and registers their tools. You can be explicit instead withWithTools<ExpenseTools>(), which I’d prefer in a real codebase. Scanning means the later parts add tool classes without touching this file.
If you’re used to ASP.NET Core you might be looking for the part that handles incoming requests. There isn’t one. AddMcpServer registers the server as a hosted service, so RunAsync starts it and blocks until the transport closes, and the SDK dispatches methods like tools/list and tools/call to the tools it found at startup.
Build it to check everything compiles:
|
1 |
dotnet build |
Step 7: Register the server with VS Code
Back at the root of the folder, edit .vscode/mcp.json. If you followed Part 1 you already have one with the Microsoft Learn server in it, so add ours alongside:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
{ "servers": { "microsoft-learn": { "type": "http", "url": "https://learn.microsoft.com/api/mcp" }, "bitoftech-expenses": { "type": "stdio", "command": "dotnet", "args": [ "run", "--project", "${workspaceFolder}/src/BitOfTech.Expenses.Mcp" ] } } } |
Let’s review the parameters:
typeisstdiothis time, nothttp. VS Code will launch a process rather than call a URL.commandandargsare what gets executed.${workspaceFolder}resolves to the folder you have open, so the file works for anyone who clones the repo.
We made a fuss about stdout a moment ago, so it’s fair to ask whether the build output from dotnet run corrupts the stream. It doesn’t. MSBuild writes to stderr, and the first thing the client sees on stdout is valid JSON-RPC. That makes dotnet run the friendlier option, because you don’t have to build first or work out where the binary ended up.
Save the file, then click the Start action that appears above the bitoftech-expenses entry. Open the server’s output log and you should see it end with:
|
1 |
Discovered 2 tools |
Note: the first start is slower than later ones because dotnet run compiles the project before the server responds. If it times out, run dotnet build once and start it again.
Step 8: Ask Copilot a question
Open Copilot Chat, switch to Agent mode, and check that bitoftech-expenses appears in the tools picker with both tools listed. Then ask:
|
1 |
Which expense reports were submitted by Sara, and what did they cost? |
Expand the tool call in the reply. You’ll see search_expense_reports was called with employeeName set to something like “Sara”, and you’ll see the JSON that came back.
Nothing in that exchange was configured by you. You never told VS Code that a tool called search_expense_reports exists, never wrote a JSON schema, and never mapped the question onto a parameter.
What your server actually told VS Code
It’s worth looking at what the client received, because it explains why the model behaved the way it did. Here’s the schema the SDK generated for search_expense_reports, straight off the wire:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
{ "type": "object", "properties": { "employeeName": { "description": "Part of an employee name, matched case insensitively. Omit to search across all employees.", "type": ["string", "null"], "default": null }, "status": { "description": "Only return reports in this status. Omit to return reports in any status.", "type": ["string", "null"], "enum": ["Draft", "Submitted", "Approved", "Rejected", null], "default": null } } } |
Look at what happened to the enum. ExpenseReportStatus? became a list of allowed values. The model can’t invent a status, because the schema says those five are the only options. It also can’t pass an integer, because the SDK serialises the enum by name in both directions.
That’s a good reason to reach for real types in your tool signatures rather than taking everything as a string. The stronger your C# types, the tighter the schema, and the less room the model has to get creative.
Your [Description] text came through untouched, on the method and on both parameters. The model has nothing else to go on.
One question the server gets wrong
Now try this:
|
1 |
Which expense reports has Lina submitted? |
Lina has exactly one report in our seed data. It was submitted on 28 August and later rejected.
The trouble is that “submitted” means two different things in that sentence, and the model has to pick one:
- Reports Lina has filed at any point, which is one report.
- Reports currently sitting in
Submittedstatus, which is none.
If the model adds status: "Submitted" to the call, the tool returns an empty array, and you get told that Lina hasn’t submitted any expense reports. That answer is confident, well formatted and wrong.
Here’s what makes this worth your attention. Nothing failed. The tool ran correctly and returned precisely what was asked for. The bug is in this sentence:
Only return reports in this status. Omit to return reports in any status.
That description is accurate and useless. It explains the mechanism and says nothing about the trap. The model has no way to know that approved and rejected reports were also submitted at some point.
I’m leaving it broken for now. Part 3 is about designing tools a model can use correctly, and we’ll fix this one there and watch the answer change.
The same server in Claude Desktop
Since stdio is stdio, nothing about our server is specific to VS Code. Claude Desktop will run it too.
Important note: you need a current version of Claude Desktop for this. Custom MCP servers live under Settings, Developer, and that section isn’t there in older builds.
Point it at the compiled executable rather than at dotnet run. Open Settings, Developer, Edit Config, and add:
|
1 2 3 4 5 6 7 |
{ "mcpServers": { "bitoftech-expenses": { "command": "C:\\PATH\\TO\\YOUR\\REPO\\src\\BitOfTech.Expenses.Mcp\\bin\\Debug\\net10.0\\BitOfTech.Expenses.Mcp.exe" } } } |
Two things to get right:
- Replace the path with the real path on your machine. Claude Desktop has no
${workspaceFolder}, so nothing will expand it for you, and the path has to be absolute. - Run
dotnet buildfirst, and again whenever you change the code. Pointing at the binary means Claude never builds anything, so it will happily keep running an old version.
Using dotnet run --no-build with the project path works too, and has the same rebuild caveat. I prefer the executable because it doesn’t assume the .NET SDK is installed, which matters the moment you hand your server to somebody else.
Restart Claude Desktop and the tools appear.
Ask it “what are the expenses sent by Sara?” and Claude asks for permission before calling the tool. Nothing runs until you approve it.
Allow it, and you can expand the tool call to see the exact request Claude sent and the raw JSON our server returned, followed by the answer written from that data.
That’s the payoff from Part 1. One server, and every host that speaks the protocol can use it.
What this server isn’t yet
Worth being clear about the gaps, because the next few parts close them:
- The data disappears when the process stops. Part 4 puts it in Azure SQL.
search_expense_reportsreturns whole reports, line items and all. Searching for Sara returns 846 characters for two reports, and that grows linearly with your data. Part 4 deals with it properly.- Anyone who can run the process gets everything. There’s no notion of who is asking. Part 7 fixes that.
- It only runs on your machine. Part 5 moves it to HTTP and Part 8 deploys it.
Summary
An MCP server in .NET is a generic host, a transport, and some methods with attributes on them. The SDK generates the JSON Schema from your method signatures, so the work that’s left to you is choosing good types and writing descriptions the model can act on.
Two things are worth carrying forward. Keep stdout clear for the protocol and send logs to stderr. And treat your [Description] text as part of your API, because it’s the only thing the model sees.
In Part 3 we add a resource and a prompt, and we go properly into tool design, starting with the Lina question and why our server answers it badly.
The source code for this tutorial is available on GitHub.
Follow me on X @TJoudeh and on LinkedIn
References:
- MCP C# SDK documentation
- MCP C# SDK on GitHub
- Build an MCP server with .NET
- MCP specification, tools
- Use MCP servers in VS Code







Leave a Reply