All articles
AI/September 19, 2026/6 min read

Building an Agentic System in .NET, Part 2 Writing an MCP Server in C#

Learn how to expose your own tools to any AI agent by building a Model Context Protocol server in C# using the official ModelContextProtocol SDK v2.2.0 — covering tool design, input validation, context window budgeting, and the naming mistakes that make models misuse your tools silently.

Why MCP, and Why Now

Part 1 of this series built the harness: the turn loop, tool dispatch, transport, and persistence. Tool dispatch was left as an interface. This part fills it in from the other side of the contract, where you expose capabilities — history search, workspace state, bus messaging — so any agent (Claude, Copilot, a custom orchestrator) can invoke them without you writing agent-specific adapters for each.

That is the promise of the Model Context Protocol. The C# SDK hit v1.0 on February 25, 2026, and the current stable release is v2.2.0 (August 13, 2026). Be warned: v2.0.0, which shipped July 28, changed several defaults, and roughly every tutorial — including what most LLMs will generate for you — still describes 1.x behaviour. Pin your NuGet version.

<PackageReference Include="ModelContextProtocol" Version="2.2.0" />

The SDK is co-maintained by Microsoft and Anthropic, targets the MCP spec revision 2025-11-25, and covers .NET 8 LTS, .NET 9, and .NET 10 via netstandard2.0.


The Attribute-Based Tool Model

The SDK's primary authoring surface is attribute-driven. You decorate a class with [McpServerToolType], decorate each public method you want to expose with [McpServerTool], and the SDK generates the JSON schema from the C# type signatures automatically. No hand-written schema, no glue code.

Here is a realistic tool class for a history-search domain:

using ModelContextProtocol.Server;
using System.ComponentModel;
using System.Threading;
using System.Threading.Tasks;
 
[McpServerToolType]
public sealed class HistoryTools
{
    private readonly IHistoryRepository _repo;
 
    public HistoryTools(IHistoryRepository repo) => _repo = repo;
 
    [McpServerTool]
    [Description(
        "Search the audit history for a tenant. " +
        "Returns events matching the query string, ordered by timestamp descending. " +
        "Use this when the user asks what happened, what changed, or who performed an action. " +
        "Set limit between 1 and 50; defaults to 10 if omitted. " +
        "NEVER use this tool to retrieve live resource state — call workspace_get_resource instead.")]
    public async Task<SearchHistoryResult> history_search_events(
        [Description("The tenant identifier (UUID).")] string tenantId,
        [Description("Free-text query, e.g. 'order cancelled by admin'.")] string query,
        [Description("Maximum events to return. Range: 1–50.")] int limit = 10,
        CancellationToken cancellationToken = default)
    {
        if (string.IsNullOrWhiteSpace(tenantId))
            throw new McpException("tenantId is required.");
        if (string.IsNullOrWhiteSpace(query))
            throw new McpException("query must not be empty.");
        limit = Math.Clamp(limit, 1, 50);
 
        var events = await _repo.SearchAsync(tenantId, query, limit, cancellationToken);
        return new SearchHistoryResult(events);
    }
}

A few things worth calling out:

  • CancellationToken is resolved automatically by the SDK — you do not pass it from the host.
  • DI-injected services (IHistoryRepository) arrive through the constructor; the SDK creates tool instances from the DI container.
  • McpException signals a recoverable tool-level error. The agent receives an error content block and can retry or escalate. Reserve McpProtocolException for genuine protocol violations.
  • limit is clamped server-side regardless of what the model sends. The server must assume hostile arguments.

Hosting the Server over stdio

For local editor integrations, Claude Desktop, or Claude Code, stdio is the right transport. The entire host is a console application — no ASP.NET Core required:

// Program.cs
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. Any stray bytes on stdout corrupt the JSON-RPC stream.
builder.Logging.AddConsole(opts =>
    opts.LogToStandardErrorThreshold = LogLevel.Trace);
 
builder.Services
    .AddScoped<IHistoryRepository, SqlHistoryRepository>()
    .AddScoped<IMemoryStore, RedisMemoryStore>()
    .AddScoped<IBusClient, ServiceBusClient>()
    .AddMcpServer()
    .WithStdioServerTransport()
    .WithToolsFromAssembly(); // scans for [McpServerToolType] in entry assembly
 
await builder.Build().RunAsync();

The LogToStandardErrorThreshold = LogLevel.Trace line is non-negotiable. One Console.WriteLine anywhere in your dependency chain and the MCP client will fail to parse the response frame.

For production, remote deployments, or multi-tenant services, swap to ModelContextProtocol.AspNetCore and .WithHttpTransport(). Note: stateless HTTP cannot push unsolicited tool-list-changed notifications; if you need dynamic tool surfaces, you need stdio or a stateful session.

Registering the Server with a Client

For Claude Desktop or any MCP-aware client, add a config entry pointing at your compiled binary:

{
  "mcpServers": {
    "my-platform-server": {
      "command": "dotnet",
      "args": ["run", "--project", "src/MyPlatform.McpServer", "--no-build"],
      "env": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}

In CI, publish a self-contained binary and reference it directly — no SDK installation required on the agent host.


The Design Question Most Teams Get Wrong: Tool Granularity and Naming

The number one failure mode is mirroring your REST API 1:1 as MCP tools. You end up with GET /orders/{id}get_order, GET /orderslist_orders, POST /orderscreate_order, and so on — 40 tools before you've covered two domains. The model has no guidance about intent, and the tool schemas alone consume 5,000–10,000 tokens for 20 tools (per AWS Prescriptive Guidance). With 50 tools, you're burning 20,000–25,000 tokens before the first user message.

Cursor surfaces a hard warning at 40 tools. The practical ceiling for most models is lower than that once you account for conversation history.

Naming Rules That Actually Matter

Tool names are not metadata — they are the dropdown the planner LLM scans on every turn. The convention the spec recommends: verb_object in lowercase snake_case, one verb per operation, no abbreviations, prefixed by domain. history_search_events, not histSearch or getEvents.

Two tools named fetch_user and get_user pointing at different backends will be chosen interchangeably based on whichever "sounds more authoritative" in the current sentence. Rename one. The model is doing fuzzy semantic matching, not exact lookup.

The MCP spec allows descriptions up to 1,024 characters. Use them. Research from Hasan et al. (arXiv 2602.14878, February 2026) found that compact but well-augmented descriptions preserve behavioural reliability while reducing token overhead. Concreteness beats length: explain when to call the tool, when not to call it, and what the output shape is. Anthropic's own MCP servers leave 72% of parameters undescribed, causing models to guess inputs rather than request clarification.

A Real 15-Tool Surface

Here is how a platform team might group 15 tools without blowing the context window:

Domain Tools
History history_search_events, history_get_event_detail
Memory memory_store_fact, memory_recall_facts, memory_delete_fact
Bus Messaging bus_publish_event, bus_get_dead_letter, bus_replay_event, bus_list_subscriptions
Workspaces workspace_list, workspace_get_resource, workspace_create, workspace_archive, workspace_set_metadata

Fifteen tools in four domains is manageable. Each group has a clear semantic boundary, the verb set is consistent (search, get, list, store, recall, publish, replay, create, archive, set), and a planner can reason about domain boundaries without reading every description.


When the Tool Surface Gets Too Large

If your platform genuinely needs 50+ tools, load them lazily. Keep a single platform_discover_tools tool always registered; its description explains the domains available. When the agent calls it with a domain name, the server registers and returns the domain-specific tools dynamically (use tool-list-changed notifications over stdio or stateful sessions for this). The always-loaded context stays small.

For tools that return large payloads — unpaginated list responses, raw documents — return a resource URI instead of embedding the content inline. The agent fetches it on demand via resources/read. A production incident at RunPod traced a complete context-window exhaustion to a single list call returning approximately 15× the size of Claude Code's context window.

Finally, the MCP spec update of 2026-07-28 recommends that tools/list responses arrive in deterministic order to enable LLM provider prompt-prefix caching. Sort your tool registrations alphabetically or by domain and keep the order stable across deployments.


Input Validation: Assume Hostile Arguments

The MCP server sits at a trust boundary. The argument payload arrives as JSON; the model constructed it. That means:

  • Clamp numeric ranges server-side (Math.Clamp, not trust).
  • Reject empty strings explicitly, not via downstream NullReferenceException.
  • Never surface credentials or secrets in tool responses — not even masked.
  • Return the smallest useful result shape. A tool that returns a 200-field object for a lookup that needs three fields is a context-window leak.

The MCP Toolbox style guide (v1.9.0, August 14, 2026) mandates these points explicitly. Treat every [McpServerTool] method as if it were a public HTTP endpoint with no authentication in front of it.


When MCP Is Overkill

If the tools are consumed by a single application that already uses Semantic Kernel, [KernelFunction] attributes carry significantly less overhead — no transport, no JSON-RPC, no separate process. MCP pays for itself when the same tool surface needs to be consumed by multiple, heterogeneous agents or editors: VS Code Copilot, Claude Code, a custom orchestrator, and a CLI tool all reading from the same server binary. That multi-consumer scenario is where the protocol overhead is worth paying.

.NET 11 Preview 4 now ships an mcpserver project template directly in the SDK (dotnet new mcpserver), which scaffolds the host, a sample tool class, and the stderr logging configuration out of the box — useful starting point if you're bootstrapping a new project rather than retrofitting an existing service.

Sources

  1. Build an MCP Server in C# (.NET 10) — 2026 Guide
  2. NuGet Gallery | ModelContextProtocol 2.2.0
  3. Model Context Protocol
  4. Building Your First MCP Server with .NET and Publishing to NuGet - .NET Blog
  5. NuGet Gallery | ModelContextProtocol 0.6.0-preview.1
  6. NuGet Gallery | ModelContextProtocol.NET.Core 0.3.3-alpha
  7. Using the NuGet Model Context Protocol (MCP) Server | Microsoft Learn
  8. GitHub - modelcontextprotocol/csharp-sdk: The official C# SDK for Model Context Protocol servers and clients. Maintained in collaboration with Microsoft. · GitHub
Share