MCP en C# : exposer vos propres outils à n'importe quel client IA

Serveur MCP C# connecté à Claude Desktop

MCP en C# : exposer vos propres outils à n'importe quel client IA

Dans le post sur les agents IA avec Ollama, j’avais bricolé un système d’appel d’outils maison : un format TOOL: nom parsé à la main, un registre d’outils, une boucle ReAct. Ça fonctionnait, mais c’était du code jetable. Chaque nouveau client IA allait demander une intégration différente.

MCP (Model Context Protocol) est la réponse standardisée à ce problème : un contrat commun entre les clients IA (Claude Desktop, VS Code Copilot, Cursor…) et les serveurs d’outils. Vous écrivez un serveur une fois, vous le branchez partout.

Dans ce billet : construire un serveur MCP minimal en C#, avec un exemple météo concret (API publique, aucune clé requise), et le brancher à Claude Desktop.

Ce que MCP résout

Sans MCP, chaque intégration d’outil est ad hoc :

  • OpenAI a son format de function calling
  • Ollama a le sien (compatible OpenAI)
  • Chaque agent maison a le sien

MCP définit un protocole standard (JSON-RPC 2.0) : le serveur expose des outils, le client les découvre et les appelle, le LLM décide quand les utiliser.

Trois primitives côté serveur : Tools (fonctions appelables), Resources (données injectées dans le contexte), Prompts (gabarits réutilisables). Dans ce billet, je couvre les Tools : le cas d’usage le plus direct.

Le SDK C# (ModelContextProtocol)

Il y a un SDK officiel, co-maintenu par Anthropic et Microsoft :

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

Note importante : c’est en preview (0.9.0-preview.1 au moment d’écrire). L’API peut changer avant la 1.0. J’épingle la version dans le .csproj pour éviter les surprises.

L’exemple : météo via Open-Meteo

J’ai choisi Open-Meteo : API météo publique, gratuite, aucune clé requise. Ça permet de rester concentré sur MCP plutôt que sur la gestion de l’authentification.

Le code complet est disponible ici : mongeon/code-examples · dotnet/mcp/mcp-weather-server

Structure du projet

mcp-weather-server/
  mcp-weather-server.csproj
  Program.cs
  WeatherTools.cs
  WeatherService.cs
  GeocodingResponse.cs
  OpenMeteoResponse.cs
  WeatherResult.cs

Program.cs

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<WeatherService>();

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

await builder.Build().RunAsync();

Le WithStdioServerTransport() est le cœur : le client IA lance le serveur comme processus enfant et communique via stdin/stdout en JSON-RPC 2.0. Aucun port réseau à configurer.

Le WithToolsFromAssembly() découvre automatiquement les méthodes marquées [McpServerTool] dans l’assembly.

WeatherService.cs

public class WeatherService(HttpClient httpClient)
{
    public async Task<WeatherResult?> GetCurrentWeatherAsync(string city)
    {
        // Étape 1 : géocodage
        var geoUrl = $"https://geocoding-api.open-meteo.com/v1/search" +
                     $"?name={Uri.EscapeDataString(city)}&count=1&format=json";

        var geoResponse = await httpClient.GetFromJsonAsync<GeocodingResponse>(geoUrl);
        var location = geoResponse?.Results?.FirstOrDefault();
        if (location is null) return null;

        // Étape 2 : météo actuelle
        var weatherUrl = $"https://api.open-meteo.com/v1/forecast" +
                         $"?latitude={location.Latitude}&longitude={location.Longitude}" +
                         "&current=temperature_2m,apparent_temperature," +
                         "weathercode,windspeed_10m,relative_humidity_2m&timezone=auto";

        var weather = await httpClient.GetFromJsonAsync<OpenMeteoResponse>(weatherUrl);
        if (weather?.Current is null) return null;

        return new WeatherResult(
            City: location.Name,
            Country: location.Country,
            Temperature: weather.Current.Temperature,
            ApparentTemperature: weather.Current.ApparentTemperature,
            Humidity: weather.Current.RelativeHumidity,
            WindSpeed: weather.Current.WindSpeed,
            Condition: WmoCodeToDescription(weather.Current.WeatherCode)
        );
    }

    private static string WmoCodeToDescription(int code) => code switch
    {
        0 => "Clear sky",
        1 => "Mainly clear",
        2 => "Partly cloudy",
        3 => "Overcast",
        45 or 48 => "Fog",
        51 or 53 or 55 => "Drizzle",
        61 or 63 or 65 => "Rain",
        71 or 73 or 75 => "Snow",
        80 or 81 or 82 => "Rain showers",
        95 => "Thunderstorm",
        _ => "Unknown"
    };
}

Les modèles de désérialisation (GeocodingResponse, OpenMeteoResponse, WeatherResult, etc.) sont dans le repo complet.

WeatherTools.cs

[McpServerToolType]
public static class WeatherTools
{
    [McpServerTool]
    [Description("Gets current weather conditions for a city using the Open-Meteo API.")]
    public static async Task<string> GetCurrentWeather(
        WeatherService weatherService,
        [Description("City name (e.g. 'Montreal', 'Paris', 'London')")]
        string city)
    {
        var weather = await weatherService.GetCurrentWeatherAsync(city);

        if (weather is null)
            return $"Could not find weather data for '{city}'. Check the city name and try again.";

        return $"""
            Weather for {weather.City}, {weather.Country}:
            - Condition: {weather.Condition}
            - Temperature: {weather.Temperature}°C (feels like {weather.ApparentTemperature}°C)
            - Humidity: {weather.Humidity}%
            - Wind: {weather.WindSpeed} km/h
            """;
    }
}

Deux points sur la signature :

  1. WeatherService weatherService: le SDK l’injecte depuis le conteneur DI. Pas de [FromServices], pas de plomberie manuelle.
  2. La [Description] sur le paramètre city: c’est ce que le LLM lit pour savoir quoi passer. Essayez d’être précis ici.

Brancher à Claude Desktop

La config vit dans un fichier JSON :

  • Windows : %APPDATA%\Claude\claude_desktop_config.json
  • macOS : ~/Library/Application Support/Claude/claude_desktop_config.json
{
  "mcpServers": {
    "weather": {
      "command": "dotnet",
      "args": ["run", "--project", "C:\\chemin\\vers\\mcp-weather-server"]
    }
  }
}

Ou si vous publiez un exécutable autonome :

{
  "mcpServers": {
    "weather": {
      "command": "C:\\chemin\\vers\\mcp-weather-server.exe"
    }
  }
}

Redémarrez Claude Desktop. Une icône d’outils apparaît dans la barre de saisie. Essayez : “Quel temps fait-il à Montréal?” Claude appelle GetCurrentWeather, récupère la réponse, et la formule.

VS Code Copilot (bonus)

Dans .vscode/mcp.json à la racine du workspace :

{
  "servers": {
    "weather": {
      "type": "stdio",
      "command": "dotnet",
      "args": ["run", "--project", "${workspaceFolder}/mcp-weather-server"]
    }
  }
}

Activez le mode Agent dans Copilot Chat, et le serveur devient disponible.

Un serveur, plusieurs clients

C’est là que MCP change la donne par rapport au tool-calling ad hoc du post sur les agents : le même binaire, sans modification, fonctionne avec Claude Desktop, VS Code Copilot, Cursor, et les IDEs JetBrains.

La logique métier (appeler Open-Meteo) vit dans le serveur. Le protocole est standard. Le client n’a pas à savoir comment les outils fonctionnent, juste qu’ils existent et ce qu’ils font.

Et Ollama là-dedans ?

Ollama ne supporte pas MCP nativement, il y a une issue ouverte depuis novembre 2024 qui n’est toujours pas fermée. Ollama a son propre format de tool-calling (compatible OpenAI), qui est un protocole différent.

Des bridges communautaires Python existent pour relier Ollama à des serveurs MCP, mais rien d’officiel ni de stable.

Pour l’instant : MCP, c’est surtout Claude Desktop et les IDEs. Ollama, c’est surtout les agents locaux codés explicitement. Les deux coexistent, ils répondent à des besoins différents.

Ce que j’aurais aimé savoir avant

Quelques points qui m’auraient sauvé du temps :

  • Les logs sur stderr, pas stdout. Un Console.WriteLine dans un outil casse le transport. Tout ce qui sort sur stdout est du JSON-RPC. Le reste doit aller sur stderr, d’où le LogToStandardErrorThreshold dans Program.cs.
  • Redémarrez Claude Desktop après chaque modification de claude_desktop_config.json. Pas de rechargement à chaud.
  • La [Description] compte. C’est ce que le LLM lit pour décider si l’outil est pertinent. Une description vague = un outil sous-utilisé.
  • Épingle la version du SDK. Il est en preview. Une mise à jour non contrôlée peut changer l’API.

Ressources

Bons outils (et gardez stdout propre).


Suggestions de lecture :