This article is also available in English

Wie bereits in meiner Kolumne Ende 2024 zum Thema Spring AI gesagt, entwickelt sich das gesamte KI-Ökosystem in einer rasanten Geschwindigkeit voran. KI-basierte Anwendungen, wie agentische Systeme, müssen dabei immer mit ihrer Umwelt agieren, um benötigte Daten zu erhalten oder Aktionen auszuführen. Wir müssen diese also immer in eine bestehende Landschaft von anderen Systemen integrieren.

Natürlich können wir diese Integrationen in unserem Anwendungsfall spezifisch implementieren. Aus Sicht von Ersetzbarkeit und Aufwandsvermeidung wäre es jedoch praktisch, wenn wir einen Standard nutzen könnten. Genau hier kommt das Model Context Protocol (MCP) ins Spiel.

Model Context Protocol

Das MCP wurde Ende November 2024 von Anthropic ins Leben gerufen. Das Protokoll besteht dabei primär aus einer Spezifikation und den dazu passenden Software Development Kits (SDKs) in diversen Sprachen. Zusätzlich gibt es noch ein Repository, in dem verschiedene Server-Implementierungen auf Basis von MCP gesammelt werden.

Die Spezifikation definiert dabei auf der obersten Ebene drei Kernelemente. Der Host ist die eigentliche Anwendung, die über das MCP um Features erweitert werden kann. Hierzu erzeugt und verwaltet der Host einen oder mehrere Clients. Er stellt dabei auch sicher, dass von Clients angeforderte Daten nur nach expliziter Zustimmung des Nutzenden weitergegeben werden. Außerdem ist er generell für die gesamte Koordination verantwortlich.

Die vom Host erzeugten Clients sind dafür verantwortlich, die Kommunikation mit genau einem Server zu verwalten. Hierzu bauen diese eine mit Zustand behaftete Session auf und können anschließend mit dem Server im Namen des Hosts kommunizieren.

Der Server selbst beinhaltet dann spezifische Funktionalität, die der Host bei Bedarf nutzen kann. Jeder Server hat dabei idealerweise einen spezifischen Fokus und konzentriert sich somit auf genau eine Aufgabe. Ein Server kann entweder als vom Client gestarteter Prozess oder separat laufen.

Die gesamte Spezifikation stützt sich auf die vier folgenden Prinzipien:

Neben einer Reihe von Features, über welche die eigentliche Funktionalität abgebildet wird, wurde auf Basis dieser Prinzipien auch ein Protokoll definiert. Dieses spezifiziert die Kommunikation zwischen Client und Server und beeinflusst dadurch auch das Interface zwischen Host und Client.

Das MCP-Protokoll

Das Protokoll basiert auf der JSON-RPC-Spezifikation in Version 2.0. Diese wiederum definiert im Grunde die zwei JSON-Objekte Request und Response, die für Funktionsaufrufe und deren Antwort genutzt werden.

Ein Request besteht dabei aus den vier Feldern jsonrpc, method, params und id. Das Feld jsonrpc ist vom Typ String und gibt die Version des Protokolls an. Somit ist es in dieser Version immer 2.0. Auch method ist vom Typ String und beinhaltet den Namen der Funktion, welche aufgerufen werden soll. params wird für die Parameter der aufzurufenden Funktion genutzt und ist vom Typ Objekt. Somit spielt die Reihenfolge der Parameter keine Rolle, da diese über den Namen gebunden werden, sie können aber selbst wiederum auch komplexe Objekte sein und sind somit nicht auf primitive Datentypen eingeschränkt. Benötigt die Funktion keine Parameter, kann das Feld auch weggelassen werden. Das letzte Feld id wird für die Zuordnung von Aufrufen zu Antworten genutzt. Hierzu füllt der Client es mit einem String oder einer ganzen Zahl. Die Antwort auf genau diesen Aufruf enthält dann denselben Wert. Wird dieses Feld weggelassen, handelt es sich um eine Notification, vergleichbar mit einem void-Aufruf in Java, es wird also auf diesen Aufruf keine Antwort geben.

Wie auch der Request enthält die Response das Feld jsonrpc mit dem aktuell fixen Wert 2.0. Weiterhin hat auch die Response das Feld id, das wie oben schon gesagt den Wert von id aus dem Aufruf enthält. Zusätzlich enthält eine Response nun entweder das Feld result oder error. Ist das Feld result vorhanden, war der Aufruf erfolgreich und das Feld, vom Typ Objekt, enthält das Ergebnis. Im Fehlerfall hingegen wird das Feld error verwendet. Dieses Objekt besteht wiederum aus einem code, einer message und optional data.

Listing 1 zeigt, als TypeScript-Definition, noch einmal alle vier definierten Fälle.

// Request
{
  jsonrpc: "2.0";
  id: string | number;
  method: string;
  params?: {
    [key: string]: unknown;
  };
}

// Notification
{
  jsonrpc: "2.0";
  method: string;
  params?: {
    [key: string]: unknown;
  };
}

// Successful Response
{
  jsonrpc: "2.0";
  id: string | number;
  result?: {
    [key: string]: unknown;
  }
}

// Error Response
{
  jsonrpc: "2.0";
  id: string | number;
  error?: {
    code: number;
    message: string;
    data?: unknown;
  }
}

Aufbauend darauf besteht das Protokoll aus einem definierten Lebenszyklus, welcher aktuell aus drei Phasen besteht. In der Initialization-Phase baut der Client eine Verbindung zum Server auf. Hierzu ruft der Client die Funktion initialize, siehe Listing 2, auf.

{
  "jsonrpc": "2.0",
  "id": "4711",
  "method": "initialize",
  "params": {
    "protocolVersion": "2024-11-05",
    "capabilities": {
      "roots": {
        "listChanged": true
      },
      "sampling": {}
    },
    "clientInfo": {
      "name": "IrgendeinClient",
      "version": "1.2.3"
    }
  }
}
Listing 2: initialize Request

Dabei teilt der Client dem Server Informationen zur Protokollversion und die von ihm unterstützten Features mit. Der Server antwortet anschließend mit den von ihm unterstützten Features, siehe Listing 3.

{
  "jsonrpc": "2.0",
  "id": "4711",
  "result": {
    "protocolVersion": "2024-11-05",
    "capabilities": {
      "prompts": {
        "listChanged": true
      },
      "tools": {}
    },
    "serverInfo": {
      "name": "MeinServer",
      "version": "0.8.15"
    }
  }
}
Listing 3: initialize Response

Stellt der Client dabei fest, dass ihm die vom Server übermittelte Protokollversion nicht passt, beendet dieser die Verbindung zum Server. Passt diese, sendet der Client zum Abschluss dieser Phase noch eine Notifikation, siehe Listing 4, an den Server.

{
  "jsonrpc": "2.0",
  "method": "notifications/initialized"
}
Listing 4: initialize Notification

Die Session befindet sich nun in der Phase Operation. In dieser kommunizieren Client und Server, basierend auf den vorher ausgehandelten Features, miteinander.

Soll die Session beendet werden, beginnt die Phase Shutdown. In dieser beendet eine der beiden Seiten, meistens der Client, die Verbindung. Wie dies geschieht, hängt vom verwendeten Transportmechanismus ab.

Als Transportmechanismen sieht MCP aktuell die beiden Wege stdio und HTTP with SSE vor. Im Falle von stdio startet der Client den Server als Kindprozess des Hosts und nutzt anschließend die Standardeingabe dieses Prozesses, um Requests an den Server zu schicken. Der Server beantwortet wiederum die Anfragen über die Standardausgabe. Die Fehlerausgabe wird für vom Server erzeugte Log-Nachrichten genutzt. Der Client kann diese ignorieren oder seinerseits verarbeiten. Das Beenden der Verbindung funktioniert in diesem Falle dadurch, die Standardeingabe zu schließen und darauf zu warten, dass der Kindprozess sich beendet. Sollte das nicht funktionieren, kann der Client auch ein SIGTERM gefolgt von, nach einer gewissen Wartezeit, einem SIGKILL schicken.

Im Falle von HTTP with SSE als Transportmechanismus öffnet der Client eine Server-Sent-Event-Verbindung zum Server und erhält als erstes Event ein endpoint-Event. Dieses enthält eine URI, welche der Client anschließend nutzen kann, um per HTTP POST Requests an den Server zu schicken. Dieser beantwortet diese Requests anschließend über die zuvor aufgebaute SSE-Verbindung.

Neben den Nachrichten, dem Lebenszyklus und den Transportmechanismen ist auch die Versionierung, in Form des Datums der letzten nicht rückwärts kompatiblen Änderung, spezifiziert.

Außerdem gibt es mit Ping, Cancellation und Progress drei weitere definierte Funktionen, um die Kommunikation zwischen Client und Server zu verbessern.

Wir wollen uns jetzt aber das Herzstück von MCP, nämlich die unterstützten Features anschauen.

Die MCP-Features

MCP-Features können sowohl vom Server als auch vom Client angeboten werden. Wir wollen hier mit den Server-Features starten.

Das erste Feature ist das Bereitstellen von parametrisierbaren Prompts. Hierzu teilt der Server dem Client während der Initialisierung mit, dass er dieses Feature unterstützt. Mit dem optionalen Property listChanged kann er dabei auch noch mitteilen, dass er den Client benachrichtigt, wenn sich etwas an den angebotenen Prompts verändert. Der Client kann nun, über einen prompts/list-Request, siehe Listing 5, eine Liste der vorhandenen Prompts vom Server erhalten.

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "prompts/list"
}
Listing 5: prompts/list

Um dann den Inhalt eines spezifischen Prompts zu erhalten, kann anschließend der Request prompts/get, siehe Listing 6, genutzt werden.

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "prompts/get",
  "params": {
    "name": "write_linkedin_post",
    "arguments": {
      "content": "…"
    }
  }
}
Listing 6: prompts/get

Die Idee hinter Prompts besteht darin, dass der Host den Nutzenden diese, über eine Liste oder spezielle Befehle, zur Auswahl anbietet. Wird dann einer der Prompts ausgewählt, holt sich der Host diesen Prompt und führt ihn anschließend aus.

Neben Prompts können Server auch Tools zur Verfügung stellen, um Function Calls zu ermöglichen. Das Prinzip ist dabei identisch zu den vorherigen Prompts und dementsprechend gibt es die beiden Requests tools/list und tools/call sowie die Notifikation notifications/tools/list_changed. Zwar wird in diesem Fall der Aufruf eines Tools vom LLM angesteuert, trotzdem empfiehlt das MCP, dass vor einem solchen Aufruf der Nutzende des Hosts mindestens darauf hingewiesen, besser noch gefragt wird. Nur so kann dieser eine qualifizierte Entscheidung treffen, ob ein solcher Aufruf sicher ist und nicht aus Versehen die an die Funktion übergebenen Parameter zu einer Exfiltration von sensitiven Daten führt.

Das letzte Feature auf der Serverseite sind Resources. Diese ermöglichen es dem Host, auf die Inhalte von Dateien oder Webseiten zuzugreifen. Hierzu kann über resources/list eine Liste der zur Verfügung stehenden Ressourcen abgefragt und über resources/read können die Inhalte einer spezifischen Ressource geholt werden. Neben der Notifikation notifications/resources/list_changed, die den Client darüber informiert, dass neue Ressourcen hinzugekommen oder bestehende entfernt wurden, gibt es hier noch die Möglichkeit, sich bei Änderungen an vorhandenen Ressourcen benachrichtigen zu lassen. Hierzu wird zuerst resources/subscribe mit der passenden Ressource vom Client aufgerufen. Der Server schickt dann bei Änderungen eine notifications/resources/updated-Notifizierung.

Auch der Client kann dem Server Features zur Verfügung stellen. Aktuell werden dabei zwei Features unterstützt. Mit dem Feature Roots teilt der Client seinem Server, auf Anfrage, einen Dateisystempfad als Wurzel mit. Hierzu schickt der Server einen roots/list-Request an den Client. Somit ist es möglich, einem Client, der Ressourcen aus dem Dateisystem zur Verfügung stellt, ein Startverzeichnis mitzugeben. Beispielsweise kann es sich dabei um das aktuelle Projektverzeichnis für eine IDE als Host handeln.

Beim zweiten Feature auf Clientseite handelt es sich um Sampling. Dieses ermöglicht dem Server Zugriff auf ein LLM, indem der Request sampling/createMessage, siehe Listing 7, an den Client geschickt wird.

{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "sampling/createMessage",
  "params": {
    "messages": [
      {
        "role": "user",
        "content": {
          "type": "text",
          "text": "Summarize the following content: …"
        }
      }
    ],
    "modelPreferences": {
      "costPriority": 0.3,
      "intelligencePriority": 0.8,
      "speedPriority": 0.5
    },
    "systemPrompt": "You are a marketing manager.",
    "maxTokens": 23
  }
}
Listing 7: sampling/createMessage

Wie auch bei Function Calls wird empfohlen, den Nutzenden klar darüber zu informieren oder besser noch erlauben zu lassen, dass ein solcher Aufruf durchgeführt wird. Neben der somit verbesserten Sicherheit ermöglicht der Umweg es auch, dass der Server unabhängig vom tatsächlich gewählten LLM-Provider wird. Um dem Server aber trotzdem Einfluss auf das gewählte Modell zu geben, können die drei, auch in Listing 7 zu sehenden, Prioritäten dem Client beim Aufruf mitgeteilt werden. Der Client hat dann die Aufgabe, ein passendes, ihm zur Verfügung stehendes Modell auszuwählen.

Das Java SDK

In Zusammenarbeit mit dem Team von Spring AI gibt es mittlerweile auch ein offizielles MCP SDK für Java. Dieses wird dann auch wiederum von Spring AI genutzt, um dort Support für MCP zu bieten.

Das SDK unterteilt sich dabei zwischen Client und Server und bietet auf beiden Seiten sowohl eine synchrone als auch eine asynchrone Version an. Auf Clientseite folgt das API des SDKs dabei der Spezifikation so exakt wie nur möglich, siehe Listing 8.

// Use stdio transport
var transport = new StdioClientTransport(
    ServerParameters.builder("npx")
        .args("-y", "@modelcontextprotocol/server-everything", "dir")
        .build());

// Create client with capabilities
var client = McpClient.sync(transport)
    .capabilities(ClientCapabilities.builder()
        .roots(true)      // Enable roots capability
        .build())
    .build();

// Initialize session
client.initialize();

// Use client
// client.listTools();
// client.callTool(new CallToolRequest(…));
// client.listResources();
// client.readResource(new ReadResourceRequest(…));
// client.listPrompts();
// client.getPrompt(new GetPromptRequest(…));

// Disconnect from server
client.closeGracefully();

Und auch auf der Seite des Servers, siehe Listing 9, wird sich darum bemüht.

// Create server with capabilities
var server = McpServer.sync(transport)
    .serverInfo("MeinServer", "0.8.15")
    .capabilities(ServerCapabilities.builder()
        .prompts(true)       // Enable prompt support
        .build())
    .build();

// Prompts
var prompt = new McpServerFeatures.SyncPromptRegistration(
    new Prompt("write_linkedin_post",
               "Writes a LinkedIn post.", List.of(
        new PromptArgument("content",
                           "The content to write about.", true)
    )),
    request -> {
        // Prompt implementation
        return new GetPromptResult(description, messages);
    }
);

server.addPrompt(prompt);
// server.addTool(syncToolRegistration);
// server.addResource(syncResourceRegistration);

// Shutdown server
server.close();

Natürlich gibt es auf beiden Seiten auch Unterstützung für den Transportweg über HTTP und SSE.

Fazit

In diesem Artikel haben wir das Model Context Protocol kennengelernt. Dieses vereinfacht die Integration von verschiedenen Datenquellen und Funktionen in LLM-basierten Anwendungen. Hierzu wurde ein Programmiersprachen unabhängiges Protokoll definiert, welches die Interaktion zwischen der eigentlichen Anwendung, dem Host, und dem Code zur Integration, dem Server, über einen generischen Client ermöglicht.

Das Protokoll basiert auf JSON-RPC in Version 2.0 für die eigentliche Kommunikation und definiert darauf aufbauend einen Lebenszyklus, zwei mögliche Transportmechanismen und diverse Features, welche dann konkret implementiert werden können.

Auf der Serverseite ermöglichen Resources dabei den Zugriff auf Dateien oder andere Ressourcen. Mittels Prompts können dem Nutzenden fertige, parametrisierbare Prompts zur Verfügung gestellt werden. Und Tools erlauben die Integration von Function Calls. Der Client kann mit Sampling seinem Server Zugriff auf LLMs geben und mittels Roots den Zugriff auf Ressourcen verwalten.

Und zuletzt gibt es für Java, in Zusammenarbeit mit dem Team von Spring AI, ein SDK. Dieses erlaubt es, sowohl Clients als auch Server zu implementieren.