Créer un serveur MCP en C# et le brancher dans Claude Code

Serveur MCP en C# branché dans Claude Code

Créer un serveur MCP en C# et le brancher dans Claude Code

Partie 3 de la série “Agent Skills” : Partie 1 – C’est quoi une skill | Partie 2 – Créer sa propre skill | English

Dans le premier article et le deuxième de cette série, on a vu ce qu’est une skill et comment en créer une. Une skill donne des instructions à l’agent, mais elle ne lui donne pas accès à de nouvelles données ou de nouveaux systèmes. Pour ça, il faut des outils, et c’est le rôle de MCP.

Dans cet article, on construit un serveur MCP en C# qui expose une petite API maison, on le branche dans Claude Code, et on termine avec la différence entre un serveur MCP et une skill.

Le code complet est disponible ici : mongeon/code-examples · mcp-books-claude-code.

MCP en deux mots

MCP (Model Context Protocol) est un protocole standard (JSON-RPC 2.0) entre les clients IA et les serveurs d’outils. Le serveur expose trois types de primitives : des tools (fonctions que l’agent peut appeler), des resources (données à injecter dans le contexte) et des prompts (gabarits réutilisables). Le client les découvre, et le modèle décide quand les utiliser.

L’avantage est que vous écrivez le serveur une seule fois et qu’il fonctionne avec tous les clients : Claude Code, Claude Desktop, VS Code Copilot, Cursor. J’ai déjà couvert les détails du SDK dans mon article sur le serveur MCP météo, branché sur Claude Desktop. Ici, je me concentre sur comment envelopper un backend existant et sur l’intégration avec Claude Code.

L’API maison : un suivi de lectures

Pour l’exemple, voici une petite API de suivi de lectures en Minimal API, avec trois endpoints et un stockage en mémoire.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var books = new List<Book>
{
    new(1, "Clean Code", "Robert C. Martin", "done"),
    new(2, "The Pragmatic Programmer", "Hunt et Thomas", "reading"),
    new(3, "Working Effectively with Legacy Code", "Michael Feathers", "to-read")
};

app.MapGet("/books", () => books);

app.MapGet("/books/search", (string q) =>
    books.Where(b => b.Title.Contains(q, StringComparison.OrdinalIgnoreCase)
                  || b.Author.Contains(q, StringComparison.OrdinalIgnoreCase)));

app.MapPost("/books", (Book book) =>
{
    var created = book with { Id = books.Max(b => b.Id) + 1 };
    books.Add(created);
    return Results.Created($"/books/{created.Id}", created);
});

app.Run("http://localhost:5200");

public record Book(int Id, string Title, string Author, string Status);

Dans un vrai projet, ça pourrait être votre API interne ou n’importe quel service existant. L’important est que le backend existe déjà avec sa propre logique : le serveur MCP va simplement l’exposer.

Le serveur MCP par-dessus l’API

Le serveur MCP est un deuxième projet console, avec le SDK officiel co-maintenu par Anthropic et Microsoft :

dotnet add package ModelContextProtocol
dotnet add package Microsoft.Extensions.Hosting
dotnet add package Microsoft.Extensions.Http

Le SDK a atteint une version stable ; je vous recommande quand même d’épingler la version dans le .csproj pour éviter les surprises.

Le Program.cs ressemble beaucoup à celui de l’article météo :

var builder = Host.CreateApplicationBuilder(args);

// Les logs vont sur stderr, stdout est réservé au transport MCP (JSON-RPC)
builder.Logging.AddConsole(opts =>
{
    opts.LogToStandardErrorThreshold = LogLevel.Trace;
});

builder.Services.AddHttpClient<BooksClient>(client =>
{
    client.BaseAddress = new Uri("http://localhost:5200");
});

builder.Services
    .AddMcpServer()
    .WithStdioServerTransport()
    .WithToolsFromAssembly();

await builder.Build().RunAsync();

BooksClient est un client HTTP typé, du ASP.NET Core standard :

public class BooksClient(HttpClient httpClient)
{
    public Task<List<Book>?> GetAllAsync() =>
        httpClient.GetFromJsonAsync<List<Book>>("/books");

    public Task<List<Book>?> SearchAsync(string query) =>
        httpClient.GetFromJsonAsync<List<Book>>(
            $"/books/search?q={Uri.EscapeDataString(query)}");

    public async Task<Book?> AddAsync(string title, string author, string status)
    {
        var response = await httpClient.PostAsJsonAsync("/books",
            new Book(0, title, author, status));
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<Book>();
    }
}

public record Book(int Id, string Title, string Author, string Status);

Et les outils eux-mêmes, découverts automatiquement par WithToolsFromAssembly() :

[McpServerToolType]
public static class BooksTools
{
    [McpServerTool]
    [Description("Lists every book in the personal reading tracker, with author and status.")]
    public static async Task<string> ListBooks(BooksClient client)
    {
        var books = await client.GetAllAsync();
        if (books is null || books.Count == 0)
            return "The reading list is empty.";

        return string.Join("\n",
            books.Select(b => $"- {b.Title} ({b.Author}): {b.Status}"));
    }

    [McpServerTool]
    [Description("Searches the reading tracker by book title or author name.")]
    public static async Task<string> SearchBooks(
        BooksClient client,
        [Description("Text to look for in the title or author (e.g. 'legacy', 'Martin')")]
        string query)
    {
        var books = await client.SearchAsync(query);
        if (books is null || books.Count == 0)
            return $"No book matches '{query}'.";

        return string.Join("\n",
            books.Select(b => $"- {b.Title} ({b.Author}): {b.Status}"));
    }

    [McpServerTool]
    [Description("Adds a book to the personal reading tracker.")]
    public static async Task<string> AddBook(
        BooksClient client,
        [Description("Book title")] string title,
        [Description("Author name")] string author,
        [Description("Reading status: 'to-read', 'reading' or 'done'")] string status)
    {
        var book = await client.AddAsync(title, author, status);
        if (book is null)
            return "The book could not be added.";

        return $"Added '{book.Title}' by {book.Author} with status '{book.Status}'.";
    }
}

Remarquez qu’il n’y a aucune logique métier ici : chaque outil appelle l’API et met en forme la réponse pour le modèle. Le BooksClient est injecté automatiquement depuis le conteneur DI, et les [Description] sont ce que le modèle lit pour décider quel outil appeler et quoi lui passer. C’est le même principe que la description d’une skill dans la partie 2 : si la description est vague, l’outil ne sera pas utilisé.

Attention : rien ne doit sortir sur stdout à part le JSON-RPC. Un Console.WriteLine dans un outil va casser le transport, c’est pour cette raison que les logs sont configurés vers stderr.

Brancher le serveur dans Claude Code

Dans le dossier de votre projet, il suffit d’une commande :

claude mcp add books -- dotnet run --project ./BooksMcp

Tout ce qui suit le -- est la commande que Claude Code va lancer comme processus enfant, avec stdin/stdout comme canal JSON-RPC. Aucun port à ouvrir pour le serveur MCP lui-même; seule l’API maison écoute sur un port, et vous la démarrez à part avec un dotnet run dans un autre terminal.

Par défaut, la config est en portée local : elle vaut pour vous, dans ce projet-là. Deux autres portées existent :

  • --scope user : le serveur devient disponible dans tous vos projets
  • --scope project : la config s’écrit dans un fichier .mcp.json à la racine du dépôt, que vous versionnez pour toute l’équipe

Le .mcp.json généré est le même format que celui de Claude Desktop :

{
  "mcpServers": {
    "books": {
      "command": "dotnet",
      "args": ["run", "--project", "./BooksMcp"]
    }
  }
}

Pour vérifier que tout est branché, tapez /mcp dans Claude Code : la liste des serveurs apparaît avec leur état et leurs outils. Ensuite, testez avec une vraie demande : “Qu’est-ce que j’ai dans ma liste de lecture en ce moment?” ou “Ajoute Refactoring de Martin Fowler à ma liste à lire”. Claude Code appelle ListBooks ou AddBook et vous répond avec les données de votre API.

Si le serveur apparaît en erreur dans /mcp, le problème est presque toujours dans la commande de démarrage : un chemin de projet relatif qui ne pointe pas au bon endroit, ou l’API maison qui n’est pas démarrée.

MCP ou skill : quand utiliser quoi

La différence entre les deux est assez simple : une skill donne des connaissances à l’agent, alors qu’un serveur MCP lui donne des outils.

Une skill est un fichier Markdown chargé dans le contexte. Elle explique à l’agent comment faire quelque chose avec les moyens qu’il a déjà : vos conventions de code, votre processus de revue, la structure de vos articles de blogue. Il n’y a pas de code à déployer et vous pouvez la modifier en quelques secondes.

Un serveur MCP est du code qui s’exécute. Il donne à l’agent un accès qu’il n’a pas : votre API interne, une base de données, un système externe. Ça demande un vrai projet, avec des dépendances et de la maintenance, mais l’agent peut ensuite interroger et modifier ces systèmes directement.

Pour choisir entre les deux, je me pose la question suivante : est-ce que l’agent pourrait déjà le faire s’il savait comment? Si oui, une skill suffit. Sinon, il faut un serveur MCP. Dans notre exemple, Claude Code n’a pas accès à l’API de lectures, donc une skill ne serait d’aucune aide.

Les deux se combinent aussi très bien. Dans la partie 2, la skill github-actions-failure-debugging ne contenait pas de code : elle indiquait à l’agent quels outils du GitHub MCP Server utiliser, et dans quel ordre. Le serveur MCP fournit les outils et la skill explique comment les utiliser.

Ressources

Bonne programmation, et pensez à vérifier /mcp si vos outils n’apparaissent pas dans Claude Code.


Cet article a été rédigé avec l’aide de l’IA et révisé par moi.


Suggestions de lecture :