The complete example is in csharp/ of the examples repository. It targets .NET 10; the package also supports .NET 8 and 9.

Files in the repository

Every snippet on this page comes from these files; open them to see the whole thing.

Install

dotnet new web -n SampleShopMcp
dotnet add package ModelContextProtocol.AspNetCore --version 2.2.0

The upstream client

ShopClient.cs holds one HttpClient with the base address and X-API-Key from the environment, and translates responses into UpstreamException with a Kind of InvalidInput, NotFound, Conflict or Unavailable. It is registered as a singleton and injected into the tool class.

Tools are attributed methods

A class marked [McpServerToolType] holds methods marked [McpServerTool]. The attribute carries the name, title and annotations; [Description] on the method becomes the tool description and [Description] on each parameter becomes that property's description in the input schema. Parameter types and defaults become the schema's types and optionality.

using System.ComponentModel;
using ModelContextProtocol;
using ModelContextProtocol.Server;

[McpServerToolType]
public sealed class ShopTools(ShopClient shop)
{
    [McpServerTool(Name = "search_products", Title = "Search products", ReadOnly = true, OpenWorld = false)]
    [Description("Search the shop catalogue by phrase and optional category. Returns one page of up to `limit` products and a `nextCursor`; when nextCursor is not null, more results exist and you must say so rather than claiming the list is complete. Use get_product for full details of one item. This tool does not place orders.")]
    public async Task<SearchResult> SearchProducts(
        [Description("Words to match against product names and descriptions.")] string? query = null,
        [Description("Restrict results to one category: lighting, furniture or kitchen.")] string? category = null,
        [Description("Page size, 1 to 20.")] int limit = 5,
        [Description("Opaque token from a previous page's nextCursor. Never invent one.")] string? cursor = null,
        CancellationToken ct = default)
    {
        try
        {
            var page = await shop.SearchProducts(query, category, limit, cursor, ct);
            return new SearchResult(page.Items.Select(p => Summary(p)).ToList(), page.NextCursor);
        }
        catch (UpstreamException e) { throw Explain(e); }
    }
}

One serialisation detail that matters

The SDK's serialiser omits null properties. nextCursor must be present even when null, because that is how the assistant knows it saw the last page. The result record opts that one property out of null-omission:

public sealed record SearchResult(
    List<ProductSummary> Items,
    [property: JsonIgnore(Condition = JsonIgnoreCondition.Never)] string? NextCursor);

This is exactly the kind of detail the conformance test catches: the C# server passed every check except "reports nextCursor" until that attribute was added.

Errors the assistant can act on

Throwing McpException returns an isError result with the message as its text. Explain maps the four upstream kinds to the four instructions; the guarded cancel_order throws one with the order details when called without confirm: true.

Serve it stateless

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ShopClient>();
builder.Services
    .AddMcpServer(options =>
    {
        options.ServerInfo = new() { Name = "sample-shop", Version = "1.0.0" };
        options.ServerInstructions = "...";
    })
    .WithHttpTransport(options => options.Stateless = true)
    .WithTools<ShopTools>();

var app = builder.Build();
app.MapMcp("/mcp");
app.MapGet("/health", () => Results.Json(new { ok = true }));
app.Run($"http://{host}:{port}");

WithTools<T>() registers one class; WithToolsFromAssembly() scans for every [McpServerToolType] instead.

Run and test

node sample-api/server.mjs &
cd csharp && dotnet run
# in another shell
cd tests && npm ci && MCP_URL=http://127.0.0.1:3003/mcp npm test

Next: connect it to Claude or ChatGPT, or see what production needs.