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 :
| Tool | Rôle |
|---|---|
GetCryptoPrice | Récupère le prix d’une crypto-monnaie en temps réel |
FetchUrl | Effectue une requête HTTP et retourne le contenu |
HashData | Calcule 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 --> FLes transports disponibles
MCP supporte plusieurs modes de transport. Le plus courant en développement local est stdio :
| Transport | Cas d’usage |
|---|---|
stdio | Processus local — standard MCP, compatible avec tous les clients |
SSE (Server-Sent Events) | Serveur distant accessible via HTTP |
WebSocket | Connexion 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 responseLe handshake initial permet au client de découvrir dynamiquement les tools disponibles sans configuration statique.
Implémenter un serveur MCP en .NET
Prérequis
- .NET 10 SDK
- Le package NuGet
ModelContextProtocol
dotnet new web -n MonServeurMcpcd MonServeurMcpdotnet add package ModelContextProtocolStructure 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.csCore — 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.
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.
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 :
CryptoTools→BinanceWebSocketService(dépendance directe sur le concret) - Avec inversion :
CryptoTools→IBinanceWebSocketService←BinanceWebSocketService
flowchart LR
T[CryptoTools\nTools/] -->|dépend de| I[IBinanceWebSocketService\nCore/Interfaces/]
S[BinanceWebSocketService\nInfrastructure/] -->|implémente| ILa 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énario | Sans inversion | Avec inversion |
|---|---|---|
| Remplacer Binance par CoinGecko | Modifier CryptoTools | Écrire une nouvelle implémentation de IBinanceWebSocketService |
Tester CryptoTools en isolation | Impossible sans connexion réseau | Injecter un fake qui retourne une valeur fixe |
| Ajouter un deuxième provider crypto | Modifier le tool | Ajouter 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'infrastructurebuilder.Services.AddHttpClient<IOnlineServiceClient, OnlineServiceClient>();builder.Services.AddSingleton<IBinanceWebSocketService, BinanceWebSocketService>();
// Configuration MCPbuilder.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 deProgram.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 :
- Valider les paramètres entrants
- Orchestrer l’appel aux services
- 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
public interface IBinanceWebSocketService{ Task<decimal> GetCurrentPriceAsync(string symbol);}Implémentation dans Infrastructure
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-RPCTests 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 :
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]);}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
# Restaurer les dépendancesdotnet restore MonServeurMcp.sln
# Lancer les testsdotnet test MonServeurMcp.sln
# Démarrer le serveurdotnet run --project src/MonServeurMcp.MCP.csprojConclusion
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.