Aller au contenu Skip to main content

Construire un serveur MCP en .NET

Introduction au protocole MCP

Le Model Context Protocol (MCP) est un standard ouvert développé par Anthropic qui définit comment les modèles de langage (LLM) communiquent avec des outils et des sources de données externes. Il joue le rôle de couche d’interopérabilité entre un client IA (VS Code, Claude Desktop, un agent custom…) et des systèmes tiers.

Avant MCP, chaque intégration était ad hoc : un modèle appelait une API propriétaire d’un côté, un autre utilisait un format différent de l’autre. MCP standardise ce contrat sous forme de JSON-RPC 2.0, ce qui permet à un serveur MCP d’être réutilisé par n’importe quel client compatible.

Qu’est-ce qu’un « tool » MCP ?

Un tool est une fonction exposée par le serveur MCP et invocable par le client. Du côté du modèle, un tool ressemble à une capacité déclarée avec un nom, une description et un schéma de paramètres. Le modèle décide lui-même quand et comment l’invoquer en fonction du contexte conversationnel.

Exemples de tools typiques :

ToolRôle
GetCryptoPriceRécupère le prix d’une crypto-monnaie en temps réel
FetchUrlEffectue une requête HTTP et retourne le contenu
HashDataCalcule un hash SHA-256 d’une chaîne de caractères

Architecture du protocole

Vue d’ensemble

MCP repose sur un modèle client/serveur. Le client est le LLM ou l’application qui l’héberge ; le serveur est le processus qui expose les tools.

flowchart LR
    A[Client MCP\nVS Code · Claude Desktop · Agent] -->|JSON-RPC 2.0| B[Serveur MCP]
    B --> C[Tool 1]
    B --> D[Tool 2]
    B --> E[Tool N]
    C --> F[Système externe\nAPI · DB · WebSocket]
    D --> F
    E --> F

Les transports disponibles

MCP supporte plusieurs modes de transport. Le plus courant en développement local est stdio :

TransportCas d’usage
stdioProcessus local — standard MCP, compatible avec tous les clients
SSE (Server-Sent Events)Serveur distant accessible via HTTP
WebSocketConnexion bidirectionnelle persistante

Le transport stdio est recommandé pour démarrer : il ne nécessite aucune infrastructure réseau et est natif dans les clients comme VS Code ou Claude Desktop.

Cycle de vie d’un appel

sequenceDiagram
    participant Client as Client MCP
    participant Server as Serveur MCP
    participant Tool as Tool
    participant Infra as Infrastructure

    Client->>Server: initialize (handshake)
    Server-->>Client: capabilities (liste des tools)
    Client->>Server: tools/call {name, arguments}
    Server->>Tool: invoke(arguments)
    Tool->>Infra: appel service
    Infra-->>Tool: résultat
    Tool-->>Server: résultat formaté
    Server-->>Client: JSON-RPC response

Le handshake initial permet au client de découvrir dynamiquement les tools disponibles sans configuration statique.


Implémenter un serveur MCP en .NET

Prérequis

Fenêtre de terminal
dotnet new web -n MonServeurMcp
cd MonServeurMcp
dotnet add package ModelContextProtocol

Structure de projet recommandée

MonServeurMcp/
├── Core/
│ └── Interfaces/
│ ├── IOnlineServiceClient.cs
│ └── IBinanceWebSocketService.cs
├── Infrastructure/
│ ├── Http/
│ │ └── OnlineServiceClient.cs
│ └── WebSocket/
│ └── BinanceWebSocketService.cs
├── Tools/
│ ├── EchoTool.cs
│ ├── HttpTools.cs
│ └── CryptoTools.cs
├── appsettings.yaml
└── Program.cs

Core — les contrats

Core contient uniquement des interfaces — aucune logique, aucune dépendance externe. Une interface est un contrat : elle décrit ce qu’un service sait faire, sans préciser comment il le fait.

Core/Interfaces/IBinanceWebSocketService.cs
public interface IBinanceWebSocketService
{
Task<decimal> GetCurrentPriceAsync(string symbol);
}

Ce fichier répond à une seule question : “Qu’est-ce que ce service expose ?”. La réponse ici : une méthode qui accepte un symbole et retourne un prix. C’est tout.

Le tool qui consomme ce service ne sait pas s’il parle à Binance, à un autre exchange, ou à un fake de test. Il fait confiance au contrat, pas à l’implémentation.

Infrastructure — les implémentations

Infrastructure contient les classes qui implémentent ces contrats. C’est ici que vit le code qui ouvre des connexions WebSocket, fait des appels HTTP, parse du JSON, etc.

Infrastructure/WebSocket/BinanceWebSocketService.cs
public class BinanceWebSocketService : IBinanceWebSocketService
{
public async Task<decimal> GetCurrentPriceAsync(string symbol)
{
// Connexion WebSocket à Binance, parsing du flux...
}
}

Tools — l’orchestration

Les tools ne connaissent que les interfaces de Core. Ils ne font jamais new BinanceWebSocketService() — ils reçoivent une instance via le constructeur, injectée par le conteneur DI.

Le principe d’inversion de dépendance

Intuitivement, on pense qu’un module de haut niveau (le tool) dépend d’un module de bas niveau (le service réseau). C’est l’ordre naturel : A utilise B, donc A dépend de B.

L’inversion de dépendance retourne cette logique :

  • Sans inversion : CryptoToolsBinanceWebSocketService (dépendance directe sur le concret)
  • Avec inversion : CryptoToolsIBinanceWebSocketServiceBinanceWebSocketService
flowchart LR
    T[CryptoTools\nTools/] -->|dépend de| I[IBinanceWebSocketService\nCore/Interfaces/]
    S[BinanceWebSocketService\nInfrastructure/] -->|implémente| I

La flèche depuis CryptoTools pointe vers une interface stable, pas vers une implémentation volatile. Changer l’implémentation (remplacer Binance par un autre exchange, injecter un fake en test) ne nécessite aucune modification du tool.

En pratique, les bénéfices sont immédiats :

ScénarioSans inversionAvec inversion
Remplacer Binance par CoinGeckoModifier CryptoToolsÉcrire une nouvelle implémentation de IBinanceWebSocketService
Tester CryptoTools en isolationImpossible sans connexion réseauInjecter un fake qui retourne une valeur fixe
Ajouter un deuxième provider cryptoModifier le toolAjouter une implémentation, brancher dans la DI

Configurer Program.cs

Le point d’entrée configure le runtime MCP et enregistre tous les services dans la DI :

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using MonServeurMcp.Core.Interfaces;
using MonServeurMcp.Infrastructure.Http;
using MonServeurMcp.Infrastructure.WebSocket;
var builder = Host.CreateApplicationBuilder(args);
// Services d'infrastructure
builder.Services.AddHttpClient<IOnlineServiceClient, OnlineServiceClient>();
builder.Services.AddSingleton<IBinanceWebSocketService, BinanceWebSocketService>();
// Configuration MCP
builder.Services.AddMcpServer()
.WithStdioServerTransport()
.WithToolsFromAssembly();
await builder.Build().RunAsync();

Points clés

  • WithStdioServerTransport() : branche le transport standard via stdin/stdout. Compatible nativement avec VS Code, Claude Desktop et tout client MCP.
  • WithToolsFromAssembly() : scanne l’assembly au démarrage et enregistre automatiquement toutes les classes annotées [McpServerToolType]. Ajouter un nouveau tool ne nécessite aucune modification de Program.cs.
  • Les services sont injectés via leurs interfaces — jamais via les classes concrètes directement.

Créer un tool

Structure d’un tool

Un tool MCP en .NET est une classe annotée [McpServerToolType] dont les méthodes publiques annotées [McpServerTool] deviennent les fonctions invocables par le client.

using ModelContextProtocol.Server;
using System.ComponentModel;
[McpServerToolType]
public class EchoTool
{
[McpServerTool, Description("Retourne la valeur reçue en entrée.")]
public string Echo(string message) => message;
}

Tool avec injection de dépendance

Les tools reçoivent leurs dépendances via le constructeur, comme tout service .NET :

[McpServerToolType]
public class CryptoTools
{
private readonly IBinanceWebSocketService _binance;
public CryptoTools(IBinanceWebSocketService binance)
{
_binance = binance;
}
[McpServerTool, Description("Retourne le prix en temps réel d'une paire de crypto-monnaies (ex: BTCUSDT).")]
public async Task<string> GetCryptoPrice(
[Description("La paire de trading (ex: BTCUSDT, ETHUSDT)")] string symbol)
{
var price = await _binance.GetCurrentPriceAsync(symbol.ToUpper());
return $"{symbol[..^4]}/{symbol[^4..]}: ${price:N2}";
}
}

Responsabilités d’un tool

Un tool doit se limiter à trois rôles :

  1. Valider les paramètres entrants
  2. Orchestrer l’appel aux services
  3. Formater la sortie pour le client

Toute logique d’accès réseau, de parsing HTTP ou de connexion WebSocket appartient à la couche Infrastructure.


La couche Infrastructure

Interface dans Core

Core/Interfaces/IBinanceWebSocketService.cs
public interface IBinanceWebSocketService
{
Task<decimal> GetCurrentPriceAsync(string symbol);
}

Implémentation dans Infrastructure

Infrastructure/WebSocket/BinanceWebSocketService.cs
public class BinanceWebSocketService : IBinanceWebSocketService
{
public async Task<decimal> GetCurrentPriceAsync(string symbol)
{
var stream = $"wss://stream.binance.com:9443/ws/{symbol.ToLower()}@miniTicker";
using var ws = new ClientWebSocket();
await ws.ConnectAsync(new Uri(stream), CancellationToken.None);
var buffer = new byte[4096];
var result = await ws.ReceiveAsync(buffer, CancellationToken.None);
var json = Encoding.UTF8.GetString(buffer, 0, result.Count);
using var doc = JsonDocument.Parse(json);
return decimal.Parse(
doc.RootElement.GetProperty("c").GetString()!,
CultureInfo.InvariantCulture);
}
}

L’interface dans Core est la seule chose que les tools voient. L’implémentation WebSocket dans Infrastructure peut être remplacée (par une autre source de prix, un mock, etc.) sans toucher aux tools.


Exemple complet

L’architecture ci-dessous illustre un serveur MCP de test pour cet article, qui expose des tools HTTP, crypto et utilitaires :

flowchart LR
    C[Client MCP\nVS Code · Claude · autre] -->|JSON-RPC via stdio| P[Program.cs\nBootstrap .NET + MCP]

    subgraph M[MonServeurMcp.MCP]
      P --> R[Runtime MCP\nAddMcpServer + WithStdioServerTransport]
      R --> T1[EchoTool]
      R --> T2[HttpTools]
      R --> T3[CryptoTools]

      T2 --> I1[IOnlineServiceClient]
      T3 --> I2[IBinanceWebSocketService]

      I1 --> H1[OnlineServiceClient\nInfrastructure/Http]
      I2 --> W1[BinanceWebSocketService\nInfrastructure/WebSocket]
    end

    H1 -->|HTTP GET/POST| E1[APIs HTTP externes]
    W1 -->|WebSocket miniTicker| E2[Binance Stream]

Flux d’un appel GetCryptoPrice

sequenceDiagram
    participant Client as Client MCP
    participant Server as Program + Runtime MCP
    participant Tool as CryptoTools
    participant Svc as BinanceWebSocketService
    participant Binance as Binance WS

    Client->>Server: tools/call GetCryptoPrice("BTCUSDT")
    Server->>Tool: invoke GetCryptoPrice(symbol)
    Tool->>Svc: GetCurrentPriceAsync(symbol)
    Svc->>Binance: connect /btcusdt@miniTicker
    Binance-->>Svc: {"c":"67890.12"}
    Svc-->>Tool: decimal 67890.12
    Tool-->>Server: "BTC/USDT: $67,890.12"
    Server-->>Client: result JSON-RPC

Tests unitaires

L’un des avantages de cette architecture est la testabilité sans infrastructure réelle. Il suffit d’implémenter un fake de l’interface :

Tests/Fakes/FakeBinanceService.cs
public class FakeBinanceService : IBinanceWebSocketService
{
private readonly Dictionary<string, decimal> _prices;
public FakeBinanceService(Dictionary<string, decimal> prices)
{
_prices = prices;
}
public Task<decimal> GetCurrentPriceAsync(string symbol)
=> Task.FromResult(_prices[symbol]);
}
Tests/CryptoToolsTests.cs
public class CryptoToolsTests
{
[Fact]
public async Task GetCryptoPrice_FormatsOutputCorrectly()
{
var fake = new FakeBinanceService(new() { ["BTCUSDT"] = 67890.12m });
var tool = new CryptoTools(fake);
var result = await tool.GetCryptoPrice("BTCUSDT");
Assert.Equal("BTC/USDT: $67,890.12", result);
}
[Fact]
public async Task GetCryptoPrice_DelegatesToService()
{
var fake = new FakeBinanceService(new() { ["ETHUSDT"] = 3200.00m });
var tool = new CryptoTools(fake);
var result = await tool.GetCryptoPrice("ETHUSDT");
Assert.Contains("ETH/USDT", result);
}
}

Les tests vérifient le comportement observable (format de sortie, gestion d’erreur, délégation) sans jamais ouvrir une connexion réseau.


Externaliser la configuration

Les URLs, timeouts et autres paramètres doivent vivre dans appsettings.yaml, pas dans le code :

OnlineService:
BaseUrl: 'https://api.example.com'
Timeout: 30
UserAgent: 'MonServeurMcp.MCP/1.0'
Binance:
StreamBaseUrl: 'wss://stream.binance.com:9443/ws'

Cela permet de changer d’environnement sans recompiler le projet.


Connecter un client MCP

VS Code

Ajouter dans .vscode/mcp.json :

{
"servers": {
"mon-serveur-mcp": {
"type": "stdio",
"command": "dotnet",
"args": ["run", "--project", "src/MonServeurMcp.csproj"]
}
}
}

Claude Desktop

Ajouter dans claude_desktop_config.json :

{
"mcpServers": {
"mon-serveur-mcp": {
"command": "dotnet",
"args": ["run", "--project", "/chemin/vers/MonServeurMcp.csproj"]
}
}
}

Commandes de référence

Fenêtre de terminal
# Restaurer les dépendances
dotnet restore MonServeurMcp.sln
# Lancer les tests
dotnet test MonServeurMcp.sln
# Démarrer le serveur
dotnet run --project src/MonServeurMcp.MCP.csproj

Conclusion

Le protocole MCP standardise la façon dont les LLM interagissent avec des systèmes externes. En .NET, l’implémentation d’un serveur MCP est directe grâce au package ModelContextProtocol, mais c’est l’architecture qui détermine la maintenabilité à long terme.

Les principes appliqués dans cet article sont ceux de toute application .NET bien structurée :

  • Séparation des responsabilités entre tools, core et infrastructure
  • Inversion de dépendance via des interfaces dans Core
  • Découverte automatique des tools pour éviter le couplage à Program.cs
  • Tests sans infrastructure grâce aux fakes

Cette approche permet d’ajouter un tool, de remplacer une intégration externe ou de migrer vers un autre transport sans impacter le reste du système.


Ressources complémentaires