Einen MCP-Server in C# schreiben

Einen MCP-Server in C# schreiben

- Matt

Wenn man einem Sprachmodell Zugriff auf eigene Daten geben will, endet das bisher meistens in Bastelei: irgendein Skript, das eine API abfragt, ein selbstgebauter Prompt drumherum, und für jedes Werkzeug wieder von vorn. Das Model Context Protocol soll dafür einen gemeinsamen Nenner schaffen. Ein Server stellt Werkzeuge bereit, ein beliebiger Client kann sie aufrufen.

Seit kurzem gibt es dafür ein C#-SDK, das Microsoft gemeinsam mit dem Protokoll-Team pflegt. Es ist noch als Vorabversion markiert, funktioniert aber schon gut genug, um damit etwas Sinnvolles zu bauen.

Das Grundgerüst

Ein MCP-Server ist eine ganz normale Konsolenanwendung:

dotnet new console -n ZeitServer
cd ZeitServer
dotnet add package ModelContextProtocol --prerelease
dotnet add package Microsoft.Extensions.Hosting

Die Program.cs bleibt erfreulich kurz:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

var builder = Host.CreateApplicationBuilder(args);

builder.Logging.AddConsole(options =>
{
    options.LogToStandardErrorThreshold = LogLevel.Trace;
});

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

await builder.Build().RunAsync();

Die drei Zeilen mit dem Logging sehen nach Nebensache aus, sind aber der Punkt, an dem man beim ersten Versuch hängenbleibt. Der Stdio-Transport benutzt die Standardausgabe für das Protokoll selbst. Schreibt der Logger dort ebenfalls hin, landen Logzeilen mitten in der JSON-Kommunikation und der Client bricht mit einer Parse-Meldung ab, die einem nichts sagt. Also alles nach stderr umleiten.

Ein Werkzeug bereitstellen

Werkzeuge sind statische Methoden mit zwei Attributen. Das SDK sucht sie über WithToolsFromAssembly() selbst zusammen:

using System.ComponentModel;
using ModelContextProtocol.Server;

[McpServerToolType]
public static class ZeitTool
{
    [McpServerTool, Description("Gibt die aktuelle Uhrzeit in einer Zeitzone zurück.")]
    public static string AktuelleZeit(
        [Description("IANA-Zeitzonen-ID, zum Beispiel Europe/Berlin")] string zeitzone)
    {
        var tz = TimeZoneInfo.FindSystemTimeZoneById(zeitzone);
        return TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz).ToString("O");
    }
}

Das Description-Attribut kommt aus System.ComponentModel und ist keine Doku für Menschen, sondern das, was das Modell zu sehen bekommt. Es entscheidet darüber, ob das Werkzeug überhaupt und mit welchen Argumenten aufgerufen wird. Bei AktuelleZeit steht deshalb bewusst ein Beispiel im Parametertext: Ohne den Hinweis auf das IANA-Format probiert das Modell gerne “MEZ” oder “Berlin” und bekommt eine Exception.

Was hier auffällt: Man schreibt kein Schema, keine JSON-Definition, keine Registrierung. Signatur und Attribute reichen, den Rest erzeugt das SDK per Reflection.

Anbinden

Der Server wird nicht gestartet, sondern vom Client gestartet. In der Konfiguration steht der Pfad zur ausführbaren Datei, der Client startet den Prozess und redet über dessen Standardein- und -ausgabe mit ihm. Für die Entwicklung reicht:

dotnet run --project ZeitServer

Dann tut sich erst mal nichts, weil der Server auf Eingaben wartet. Das ist richtig so.

Wo es hakt

Das Debuggen ist unangenehm. Weil die Standardausgabe belegt ist und der Prozess fremdgestartet wird, fällt Console.WriteLine als Notnagel aus, und einen Debugger anzuhängen ist umständlich. Man landet schnell bei Logging in eine Datei, was sich nach 2005 anfühlt.

Dazu kommt, dass alles noch Vorabversion ist. Zwischen zwei Paketständen haben sich bei mir bereits Methodennamen geändert. Wer damit produktiv etwas bauen will, sollte die Version festnageln und mit Anpassungen rechnen.

Trotzdem: Der Aufwand für ein funktionierendes Werkzeug liegt bei etwa dreißig Zeilen. Das ist deutlich weniger, als es die Sache vor einem halben Jahr noch war.