Mert Özen Further With Every Line
Artificial Intelligence AI Agents and MCP — #09

Building an MCP Server in C#: ModelContextProtocol SDK 2.x, [McpServerTool] and stdio

Mert Özen Nov 8, 2026 12 min 4 views
Building an MCP Server in C#: ModelContextProtocol SDK 2.x, [McpServerTool] and stdio

You build an MCP server in C# with the ModelContextProtocol 2.x package, the Generic Host and the [McpServerTool] attributes; on the stdio transport, logs must go to stderr. We build the Bookstore tools with services from DI, structured output, resources and prompts, test them with MCP Inspector, and see with real output how polluting stdout turns into a one-minute timeout and how culture settings corrupt tool output.

Version note: The code in this post was built and run with .NET 10, ModelContextProtocol 2.4.0 and MCP (Model Context Protocol) Inspector 2.14.0.

Guess First

A tool in the Bookstore MCP server cannot connect to the database and throws this exception: InvalidOperationException("Login failed for user bookstore_app on sql-prod-01"). What does the MCP client calling the tool, and indirectly the model, see? The exception message, the stack trace, or nothing at all until a timeout?

The answer: none of those. The model only sees An error occurred invoking 'get_order_status'.; the user name, the server name and the stack trace stay in the server's stderr log. The C# SDK (Software Development Kit) does this on purpose. In this post, while turning the small program from post 8 into a real Bookstore server, we will see where this default protects you and where you can break it with your own hands.

Packages: Which One Does What?

The MCP C# SDK consists of three main packages, all released with the same version number (2.4.0 in this post):

PackageWhat's insideWhen
ModelContextProtocol.CoreClient, low-level server API (Application Programming Interface), protocol typesIf you only write a client or want minimal dependencies
ModelContextProtocolCore + stdio server, hosting, DI (Dependency Injection), attribute-based discoveryThe starting point for a stdio server
ModelContextProtocol.AspNetCoreStreamable HTTP (HyperText Transfer Protocol) server, MapMcp(), authorization helpersRemote servers (post 10)

Next to these there are two opt-in extension packages: ModelContextProtocol.Extensions.Tasks for long-running work and the experimental ModelContextProtocol.Extensions.Apps. The packages target net8.0, net9.0, net10.0 and netstandard2.0 for .NET Framework. Two packages are enough for the server in this post:

dotnet new console -n Bookstore.Mcp.Server
dotnet add package ModelContextProtocol --version 2.4.0
dotnet add package Microsoft.Extensions.Hosting

A stdio Server on the Generic Host

The server runs on the Generic Host you know from the ASP.NET Core Web API series. Configuration, logging and DI work the same:

using Bookstore.Mcp.Server;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

// stdout belongs to the protocol: every log line goes to stderr
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);

builder.Services.AddSingleton<BookCatalog>();
builder.Services.AddSingleton(new CurrentCustomer(builder.Configuration.GetValue("Bookstore:CustomerId", 7)));

builder.Services
    .AddMcpServer(o => o.ServerInfo = new() { Name = "bookstore", Version = "1.0.0" })
    .WithStdioServerTransport()
    .WithToolsFromAssembly()
    .WithResourcesFromAssembly()
    .WithPromptsFromAssembly();

await builder.Build().RunAsync();

Two lines are critical. LogToStandardErrorThreshold = LogLevel.Trace makes the console logger write every level to stderr; the default console logger writes to stdout, and on stdio stdout belongs to MCP messages only. CurrentCustomer encodes this decision: whoever starts a stdio server is already the local user, so the customer id comes from configuration, not from the model as a tool argument. The spec also recommends that stdio servers take credentials from the environment instead of running an OAuth flow.

namespace Bookstore.Mcp.Server;

// For a local stdio server the "user" is whoever started the process,
// so the customer id comes from configuration, never from a tool argument.
public sealed record CurrentCustomer(int Id);

Tools: Attributes, Descriptions and DI

The data side is deliberately boring: an in-memory catalog with 8 books and 3 orders. In real life an EF (Entity Framework) Core or Dapper repository sits here:

namespace Bookstore.Mcp.Server;

public sealed record Book(int Id, string Title, string Author, string Genre, decimal Price, int Stock);

public sealed record Order(int Id, int CustomerId, string Status, string? TrackingNumber);

public sealed class BookCatalog
{
    private readonly List<Book> _books =
    [
        new(1, "Distributed Patience", "Alan Brook", "software", 42.50m, 12),
        new(2, "The Last Lighthouse", "Mira Holt", "novel", 18.90m, 0),
        new(3, "Coffee and Compilers", "Ada Stone", "software", 35.00m, 4),
        new(4, "Winter in Kyoto", "Ken Sato", "novel", 21.00m, 7),
        new(5, "A Short History of Maps", "Lena Ford", "history", 27.50m, 3),
        new(6, "Async All the Way", "Alan Brook", "software", 39.90m, 9),
        new(7, "The Quiet Orchard", "Mira Holt", "novel", 16.50m, 15),
        new(8, "Numbers Never Lie", "Omar Reed", "science", 24.00m, 2),
    ];

    private readonly List<Order> _orders =
    [
        new(1043, 7, "Shipped", "TRK-88213"),
        new(1044, 7, "Processing", null),
        new(2001, 9, "Delivered", null),
    ];

    public IReadOnlyList<Book> Search(string query, string? genre, int maxResults) =>
        _books
            .Where(b => b.Title.Contains(query, StringComparison.OrdinalIgnoreCase)
                     || b.Author.Contains(query, StringComparison.OrdinalIgnoreCase))
            .Where(b => genre is null || b.Genre.Equals(genre, StringComparison.OrdinalIgnoreCase))
            .Take(maxResults)
            .ToList();

    public Book? Find(int bookId) => _books.FirstOrDefault(b => b.Id == bookId);

    public IReadOnlyList<string> Genres() => _books.Select(b => b.Genre).Distinct().Order().ToList();

    public Order? FindOrder(int orderId, int customerId) =>
        _orders.FirstOrDefault(o => o.Id == orderId && o.CustomerId == customerId);
}

The tools are an ordinary class that takes services through a primary constructor. The tool names match the ones from the first seven posts of the series; the tool design rules from post 3 apply here too:

using System.ComponentModel;
using Microsoft.Extensions.Logging;
using ModelContextProtocol;
using ModelContextProtocol.Server;

namespace Bookstore.Mcp.Server;

public sealed record StockInfo(int BookId, string Title, int InStock, bool Available);

[McpServerToolType]
public sealed class BookstoreTools(BookCatalog catalog, CurrentCustomer customer, ILogger<BookstoreTools> logger)
{
    [McpServerTool(Name = "search_books", ReadOnly = true)]
    [Description("Searches the Bookstore catalog by title or author. Returns at most maxResults books as 'id | title | author | price'.")]
    public string SearchBooks(
        [Description("Words from the title or the author name, e.g. 'Brook'")] string query,
        [Description("Optional genre filter: software, novel, history or science")] string? genre = null,
        [Description("Maximum number of books to return (1-10)")] int maxResults = 5)
    {
        logger.LogInformation("search_books query={Query} genre={Genre}", query, genre);
        IReadOnlyList<Book> books = catalog.Search(query, genre, Math.Clamp(maxResults, 1, 10));
        if (books.Count == 0)
        {
            return $"No books match '{query}'. Try a shorter query or remove the genre filter.";
        }

        // Tool output is read by a model, not a person: never format with the server culture
        return string.Join("\n", books.Select(b => FormattableString.Invariant($"{b.Id} | {b.Title} | {b.Author} | {b.Price:0.00} EUR")));
    }

    [McpServerTool(Name = "check_stock", ReadOnly = true, UseStructuredContent = true)]
    [Description("Returns the current stock for a book id.")]
    public StockInfo CheckStock([Description("Book id from search_books")] int bookId)
    {
        Book book = catalog.Find(bookId)
            ?? throw new McpException($"Book {bookId} does not exist. Call search_books to find a valid id.");
        return new StockInfo(book.Id, book.Title, book.Stock, book.Stock > 0);
    }

    [McpServerTool(Name = "get_order_status", ReadOnly = true)]
    [Description("Returns the status of one of the current customer's orders.")]
    public string GetOrderStatus([Description("Order number, e.g. 1043")] int orderId)
    {
        Order? order = catalog.FindOrder(orderId, customer.Id);
        if (order is null)
        {
            // Same answer for "missing" and "someone else's order": do not leak existence
            return $"Order {orderId} was not found for this customer.";
        }

        return order.TrackingNumber is null
            ? $"Order {order.Id}: {order.Status}"
            : $"Order {order.Id}: {order.Status}, tracking {order.TrackingNumber}";
    }
}

[McpServerToolType] marks the class and WithToolsFromAssembly() finds such classes. The class is created from DI for each call; BookCatalog, CurrentCustomer and ILogger in the constructor come from the container. Method parameters can also be DI services, McpServer, IProgress<ProgressNotificationValue> or ClaimsPrincipal; the SDK keeps these out of the tool schema and only includes the parameters the model is supposed to fill in.

ReadOnly = true adds the readOnlyHint annotation to the tool. It is a hint, not a guarantee: the spec states plainly that annotations from an untrusted server must be treated as untrusted. But in your own host, connecting your own server, it is useful for showing the approval screen only for tools that write.

Structured Output: outputSchema and structuredContent

check_stock returns a record instead of a string and carries UseStructuredContent = true. The SDK does two things in that case: it adds an outputSchema to the tool in tools/list and sends the result both as text and as structuredContent. Let's call it with the MCP Inspector CLI (Command-Line Interface):

mcp-inspector --cli dotnet Server/bin/Debug/net10.0/Bookstore.Mcp.Server.dll \
  --method tools/call --tool-name check_stock --tool-arg bookId=3
{
  "content": [
    {
      "type": "text",
      "text": "{\"bookId\":3,\"title\":\"Coffee and Compilers\",\"inStock\":4,\"available\":true}"
    }
  ],
  "structuredContent": {
    "bookId": 3,
    "title": "Coffee and Compilers",
    "inStock": 4,
    "available": true
  }
}

The text copy is there for backward compatibility: a host that does not know about structured output can still hand the result to the model. A host that does can validate structuredContent against the schema and use it in its own code, for example to hide the "add to cart" button when stock is zero. The 2026-07-28 revision opened outputSchema to all of JSON (JavaScript Object Notation) Schema 2020-12, and the SDK now sends non-object return values without wrapping them; a tool returning 72 produces structuredContent: 72.

Let's look at the error path too. For a book that does not exist we threw an McpException:

{"result":{"content":[{"type":"text","text":"An error occurred invoking 'check_stock': Book 99 does not exist. Call search_books to find a valid id."}],"isError":true},"id":3,"jsonrpc":"2.0"}

This is not a JSON-RPC error but a normal tool result carrying isError: true. The difference matters: the model sees this text and can correct itself. We wrote the message accordingly; the rule from post 3 is that an error message should tell the model what to do next.

Defining Resources and Prompts

Resources are data addressed by a URI (Uniform Resource Identifier). Those with a fixed URI appear in resources/list, parameterized templates in resources/templates/list:

using System.ComponentModel;
using System.Text.Json;
using ModelContextProtocol;
using ModelContextProtocol.Server;

namespace Bookstore.Mcp.Server;

[McpServerResourceType]
public sealed class BookstoreResources(BookCatalog catalog)
{
    [McpServerResource(UriTemplate = "bookstore://genres", Name = "genres", MimeType = "application/json")]
    [Description("All genres in the catalog.")]
    public string Genres() => JsonSerializer.Serialize(catalog.Genres());

    [McpServerResource(UriTemplate = "bookstore://books/{bookId}", Name = "book", MimeType = "application/json")]
    [Description("One book as JSON.")]
    public string BookById(int bookId) =>
        JsonSerializer.Serialize(catalog.Find(bookId) ?? throw new McpException($"Book {bookId} not found."));
}
mcp-inspector --cli dotnet Server/bin/Debug/net10.0/Bookstore.Mcp.Server.dll \
  --method resources/read --uri bookstore://books/6
{
  "contents": [
    {
      "uri": "bookstore://books/6",
      "mimeType": "application/json",
      "text": "{\"Id\":6,\"Title\":\"Async All the Way\",\"Author\":\"Alan Brook\",\"Genre\":\"software\",\"Price\":39.90,\"Stock\":9}"
    }
  ]
}

A prompt is a ready-made message the user picks. Returning a ChatMessage from M.E.AI (Microsoft.Extensions.AI) is enough:

using System.ComponentModel;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Server;

namespace Bookstore.Mcp.Server;

[McpServerPromptType]
public sealed class BookstorePrompts
{
    [McpServerPrompt(Name = "recommend_books")]
    [Description("Asks the assistant for book recommendations in a genre.")]
    public static ChatMessage RecommendBooks(
        [Description("Genre, e.g. software")] string genre,
        [Description("Maximum price in EUR")] decimal maxPrice) =>
        new(ChatRole.User,
            $"Recommend up to three {genre} books under {maxPrice} EUR. " +
            "Use search_books and check_stock; only recommend books that are in stock.");
}
{
  "description": "Asks the assistant for book recommendations in a genre.",
  "messages": [
    {
      "role": "user",
      "content": {
        "type": "text",
        "text": "Recommend up to three software books under 40 EUR. Use search_books and check_stock; only recommend books that are in stock."
      }
    }
  ]
}

Testing with MCP Inspector and Connecting a Host

MCP Inspector is the protocol's official test tool. It has a web UI too, but I prefer the CLI mode because it is repeatable; all the outputs above come from it. Instead of installing Inspector globally, I installed it into the project folder with npm install @modelcontextprotocol/inspector@2.14.0. Thanks to the logs the server writes to stderr, we can also see what Inspector does:

info: ModelContextProtocol.Server.McpServer[570385771]
      Server (bookstore 1.0.0) method 'initialize' request handler called.
info: ModelContextProtocol.Server.McpServer[1867955179]
      Server (bookstore 1.0.0), Client (inspector-cli 2.14.0) method 'initialize' request handler completed in 6.1051ms.
info: ModelContextProtocol.Server.McpServer[570385771]
      Server (bookstore 1.0.0), Client (inspector-cli 2.14.0) method 'logging/setLevel' request handler called.
info: ModelContextProtocol.Server.McpServer[570385771]
      Server (bookstore 1.0.0), Client (inspector-cli 2.14.0) method 'tools/list' request handler called.
info: ModelContextProtocol.Server.McpServer[570385771]
      Server (bookstore 1.0.0), Client (inspector-cli 2.14.0) method 'tools/call' request handler called.
info: ModelContextProtocol.Server.McpServer[2065726448]
      "check_stock" completed. IsError = False.

Even Inspector CLI 2.14.0 connects with the 2025-style initialize and the deprecated logging/setLevel. That is why a server that speaks both eras is a must today.

Connecting to a host is usually just a JSON configuration. Visual Studio Code's workspace .vscode/mcp.json uses the servers key:

{
  "servers": {
    "bookstore": {
      "type": "stdio",
      "command": "dotnet",
      "args": ["Server/bin/Debug/net10.0/Bookstore.Mcp.Server.dll"],
      "env": { "Bookstore__CustomerId": "7" }
    }
  }
}

The portable format used by Claude Desktop and Copilot CLI has mcpServers as the top-level key, with the same content. For this post I did not register the server in any real host's configuration; all tests were done with Inspector and with our own client that we will write in post 12. Using MCP servers inside IDEs (Integrated Development Environments) and coding assistants is the subject of the upcoming AI (Artificial Intelligence) Assisted Development series.

The env line is small but important: the Bookstore__CustomerId environment variable becomes Bookstore:CustomerId in .NET configuration. Making the same call as customer 9:

mcp-inspector --cli dotnet Server/bin/Debug/net10.0/Bookstore.Mcp.Server.dll -e Bookstore__CustomerId=9 \
  --method tools/call --tool-name get_order_status --tool-arg orderId=1043
{
  "content": [
    {
      "type": "text",
      "text": "Order 1043 was not found for this customer."
    }
  ]
}

In Production

First, the format of tool output. In the first version of search_books I wrote the price with {b.Price:0.00}. On my machine with Turkish regional settings, Inspector showed:

"text": "1 | Distributed Patience | Alan Brook | 42,50 EUR\n6 | Async All the Way | Alan Brook | 39,90 EUR"

A comma decimal next to the | separators raises the odds that the model misreads the price, and the same server writes a dot on another machine. After fixing it with FormattableString.Invariant the output became 42.50 EUR. Tool output is an API contract; it must not depend on culture, time zone or machine.

Second, identity. A stdio server runs with the rights of the user who started the process, and tool arguments come from the model. If you leave authorization decisions such as "which customer" to an argument, the model, or some text the model has read, can ask for another customer's order. That is why customerId is not a parameter but a service fed from configuration.

Third, logs. What does the host do with stderr? According to the spec, a client may capture, forward or ignore stderr and should not assume a stderr line means an error. Don't leave important events to stderr alone; in production send them to a collector with OpenTelemetry.

Fourth, configuration and versions. The server is started from a DLL (Dynamic-Link Library) path that the host configuration keeps fixed. Deploying a new version into the same folder does not change the process an already-running host is using; the old code keeps running until the host restarts the server.

Common Pitfalls

First, polluting stdout. I ran Inspector against a server with a forgotten Console.WriteLine("Looking up order 1043..."); Inspector silently skipped the non-JSON line and showed the correct answer. That shows how the problem can stay invisible. The really bad case is a Console.Write without a newline: the text sticks to the start of the next JSON message, and that line is no longer valid JSON. Inspector's result:

{"error":{"code":"error","message":"Request timed out after 1m00s (tools/call). 1 request is unanswered: tools/call (sent 1m00s ago). Last response received 1m00s ago (tools/list).","cause":"Request timed out"}}

The server ran the tool successfully and wrote the response; but the client could not read it and waited a minute. No error in the logs, everything green on the server side. Write nothing to Console.Out in a stdio server; if a third-party library writes to stdout, isolate it.

Second, leaking exceptions with your own hands. I added the exception from Guess First to the get_order_status tool of the small server from post 8 and called it with Inspector; stderr and the result the client sees (file path shortened):

fail: ModelContextProtocol.Server.McpServer[1433779783]
      "get_order_status" threw an unhandled exception.
      System.InvalidOperationException: Login failed for user bookstore_app on sql-prod-01
         at OrderTools.GetOrderStatus(Int32 orderId) in .../Program.cs:line 30
{
  "content": [
    {
      "type": "text",
      "text": "An error occurred invoking 'get_order_status'."
    }
  ],
  "isError": true
}

The SDK only passes McpException messages through to the client; for other exceptions it returns a generic message and writes the details to the log. You can remove this protection yourself with a line like catch (Exception ex) { throw new McpException(ex.ToString()); }, written to be "more helpful". An McpException message is text the model will see; put only information in it that helps the model and is safe to leave the server.

Third, confusing McpProtocolException with McpException. The first produces a JSON-RPC error response, which the model does not see; the second produces a tool result with isError, which the model does see. Errors the model can fix, such as a wrong book id, should come back as tool results.

Fourth, leaving input validation to the schema. The C# server silently clamps a maxResults of 50 to 10 and returns the whole catalog for an empty query. These should be deliberate decisions; in post 11 we will see the TypeScript version of the same server answer the same inputs very differently.

A Small Experiment

★☆☆ Change the genre parameter of search_books from string? to an enum and look at how the schema changes in the tools/list output. Can the model still send an invalid genre?

★★☆ Add an IProgress<ProgressNotificationValue> parameter to check_stock and report a slow stock query in three steps. Do these notifications show up in the Inspector CLI, and does the parameter appear in the schema?

★★★ Protect the server so that it cannot write anything to stdout: write a startup step that redirects stdout to stderr with Console.SetOut, and verify that the SDK's own transport still works. How does the SDK open stdout, such that this trick does not affect it?

While the server was local and single-user, configuration solved the identity question. In the next post we move the same tools to ASP.NET Core: Streamable HTTP, stateless mode, state handles bound to a user, and protecting the MCP server as an OAuth resource server.