Kleine Vorwarnung: Dieser Beitrag wird etwas technischer als sonst. Wer sich für MCP, JSON-RPC und OAuth interessiert, ist hier genau richtig. Alle anderen dürfen gerne direkt zum Fazit am Ende springen. 🤓

Bei Ginolino steht das Geschenk als Bild im Zentrum: Die Organisatorin lädt ein Foto hoch, setzt Teile mit Preisen darauf und die Gäste wählen ihren Anteil. Sehr oft ist das Geschenk aber ein ganz konkretes Produkt aus einem Onlineshop. Und in der Schweiz heisst dieser Shop häufig Galaxus. Also war der naheliegende Wunsch: Produkt suchen, auswählen und Bild, Name und Preis landen automatisch im Geschenk.

Lange war das nicht zuverlässig möglich. Seit Galaxus einen eigenen MCP-Server anbietet, geht es endlich sauber. In diesem Beitrag zeigen wir, wie wir ihn in unseren Laravel-Stack integriert haben, und zwar ohne LLM dazwischen.

Vorher: Scraping, das nicht funktioniert hat

Unser erster Versuch lief über einen Produktlink. Ein GalaxusScraper sollte die Produktseite laden und aus dem HTML das schema.org-JSON-LD lesen, mit Fallback auf die Open-Graph-Tags. In der Praxis hat das nicht funktioniert: Zuverlässig an Name, Preis und Bild zu kommen, war über das Scraping der Produktseite nicht möglich.

Und selbst wenn es geklappt hätte, wären die bekannten Nachteile geblieben:

  • Fragil: Jede Änderung am Markup kann die Extraktion brechen.
  • Kein Suchen: Nutzerinnen müssten zuerst auf Galaxus das Produkt finden und den Link kopieren.
  • Graubereich: Fremde Seiten automatisiert auszulesen, ist keine Grundlage, auf der man ein Produkt aufbauen möchte.

Deshalb haben wir das Feature zurückgestellt und auf eine offizielle Schnittstelle von Galaxus gewartet. Mit dem MCP-Server ist sie nun da.

Was ist MCP und warum hilft das hier?

Das Model Context Protocol (MCP) ist ein offener Standard, über den Dienste ihre Funktionen als «Tools» bereitstellen, ursprünglich gedacht für KI-Assistenten wie Claude. Galaxus bietet unter https://mcp.galaxus.ch unter anderem das Tool search_products an: Suchbegriff rein, strukturierte Produktdaten mit Name, Marke, Preis, Bild, Bewertung und Produktlink raus.

Der spannende Punkt: MCP ist technisch nichts anderes als JSON-RPC 2.0 über HTTP. Man braucht dafür kein Sprachmodell. Unser Backend spricht den Server direkt an, wie jede andere API. Wir identifizieren uns dabei auch transparent als Ginolino group-gift app (direct user search, no LLM).

Ein schlanker MCP-Client in Laravel

Für PHP gibt es noch keinen MCP-Client, der für unseren Einsatz passt. Also haben wir einen eigenen geschrieben: GalaxusMcpClient, gut 250 Zeilen, basierend auf Laravels HTTP-Client. Er implementiert den Streamable HTTP Transport der MCP-Spezifikation (Protokollversion 2025-06-18).

Der Ablauf einer Session:

  1. initialize mit Protokollversion und Client-Info. Der Server antwortet optional mit einem Mcp-Session-Id-Header.
  2. notifications/initialized bestätigt den Handshake.
  3. tools/call mit Tool-Name und Argumenten, bei jedem weiteren Request mit der Session-ID im Header.

Die Session-ID cachen wir für 30 Minuten, damit nicht jede Suche einen neuen Handshake braucht:

private function post(array $message, ?string $sessionId): Response
{
    $headers = [
        'Accept' => 'application/json, text/event-stream',
        'MCP-Protocol-Version' => self::PROTOCOL_VERSION,
    ];

    if ($sessionId !== null) {
        $headers['Mcp-Session-Id'] = $sessionId;
    }

    return Http::withHeaders($headers)
        ->timeout(20)
        ->post($this->url, $message);
}

Zwei Details der Spezifikation muss ein Client sauber behandeln:

  • JSON oder Server-Sent Events: Der Server darf entweder direkt JSON zurückgeben oder einen text/event-stream. Im zweiten Fall parsen wir die Events und suchen die Antwort mit der passenden JSON-RPC-id.
  • Abgelaufene Sessions: Antwortet der Server mit 404, hat er die Session vergessen. Dann verwerfen wir die gecachte ID und initialisieren genau einmal neu.

Das Resultat eines Tool-Calls kommt entweder als structuredContent oder als JSON-Text im content-Array. Wir nehmen, was da ist:

$text = collect($result['content'] ?? [])->firstWhere('type', 'text')['text'] ?? null;
$data = $result['structuredContent'] ?? json_decode($text ?? '', true);

Von Rohdaten zum Geschenk

Über dem Protokoll-Client sitzt GalaxusProducts, der die Fachlogik kapselt und die Daten in die Form bringt, die unser Vue-Frontend braucht.

Freitext-Suche: Der GalaxusProductPicker im Frontend sucht nach einer kurzen Pause (Debounce 450 ms, laufende Requests werden per AbortController abgebrochen) und zeigt die Treffer mit Bild und Preis an. Falls Galaxus den Suchbegriff korrigiert («Meinten Sie …»), geben wir auch das weiter.

Galaxus-Produktsuche in Ginolino: Treffer mit Bild und Preis direkt im Formular

Produktlinks: Viele haben den Link schon in der Zwischenablage. Das MCP bietet aber keinen Lookup per Produkt-ID, und eine Suche nach «12923999» liefert nichts. Die Lösung: Wir lesen aus dem URL-Slug den Produktnamen und die ID, suchen nach dem Namen und nehmen den Treffer mit der passenden ID. Auch die Sprache übernehmen wir aus dem Link (/de/, /fr/ …).

Bilder: Laut Dokumentation liefert search_products ein Vorschaubild pro Produkt. Die Originale sind aber teils 8000 Pixel breit und mehrere Megabyte gross. Galaxus' Bildservice skaliert sie serverseitig, also generieren wir zwei Varianten: 1024 px für das Geschenkbild und 200 px für die Trefferliste. Zusätzlich lesen wir aus den Metadaten der Antwort bis zu vier Produktbilder aus, damit man das schönste Bild wählen kann. Weil dieser Teil nicht dokumentiert ist, fällt der Code bei leeren Metadaten sauber auf das einzelne Vorschaubild zurück.

Normalisierung: Das MCP liefert den Namen ohne Marke («Trichter Erweiterung»), wir setzen sie davor («Hubelino Trichter Erweiterung»). Tracking-Parameter (utm_*) entfernen wir aus dem Produktlink.

Beim Erstellen des Geschenks passiert dann alles in einer Datenbank-Transaktion: Bild herunterladen, Produktlink speichern, einen ersten Geschenkteil mit Name und Preis anlegen. Schlägt der Bild-Download fehl, wird alles zurückgerollt, und es bleibt kein halbes Geschenk zurück. Auf der Seite für die Gäste erscheint anschliessend ein Link «Auf Galaxus ansehen».

OAuth: vorbereitet, aber (noch) nicht nötig

Aktuell beantwortet der Galaxus-MCP anonyme Anfragen. Darauf wollten wir uns aber nicht blind verlassen. Die MCP-Spezifikation definiert für geschützte Server OAuth 2.1 mit PKCE, und genau das haben wir in McpOAuth bereits umgesetzt:

  • Discovery nach RFC 9728: Ein unauthentifizierter initialize-Request liefert bei 401 im WWW-Authenticate-Header einen Verweis auf die Resource-Metadaten. Daraus ergibt sich der zuständige Authorization Server, dessen Endpunkte wir über RFC 8414 (bzw. OpenID-Configuration) auflösen.
  • Dynamic Client Registration nach RFC 7591: Ginolino registriert sich selbst als öffentlicher Client, ohne manuelles Setup im Galaxus-Portal.
  • Authorization Code + PKCE (S256), inklusive resource-Parameter, damit das Token an den MCP-Server gebunden ist.
  • Token-Handling: Tokens liegen verschlüsselt in der Tabelle mcp_connections, werden 60 Sekunden vor Ablauf proaktiv erneuert und bei einem 401 einmalig per Refresh-Token neu geholt.

Angestossen wird die Anmeldung einmalig über einen Artisan-Befehl. Einen öffentlich erreichbaren Callback-Endpunkt braucht es dafür nicht, man kopiert die Redirect-URL einfach zurück ins Terminal:

php artisan galaxus:connect           # Anmeldung starten
php artisan galaxus:connect --status  # Testsuche ausführen
php artisan galaxus:connect --disconnect

Sollte Galaxus also eines Tages eine Anmeldung verlangen, ist das ein Befehl und kein Umbau.

Sicherheit: externe Daten sind Benutzereingaben

Die Produktdaten kommen zwar über unser Backend, landen aber über das Frontend wieder bei uns. Deshalb behandeln wir sie wie jede andere Benutzereingabe:

  • SSRF-Schutz: Bilder laden wir nur per HTTPS von einer Allowlist (galaxus.ch, digitec.ch, digitecgalaxus.ch), mit maximal 15 MB und geprüftem Content-Type.
  • Saubere Links: Ein Produktlink wird nur gespeichert, wenn er exakt dem Muster einer Galaxus- oder Digitec-Produktseite entspricht. Alles andere wird verworfen.
  • Rate Limiting: Jede Suche löst ausgehende Requests aus, darum ist der öffentliche Endpunkt auf 30 Anfragen pro Minute begrenzt.
  • Klare Fehler: Ist Galaxus nicht erreichbar, bekommt die Nutzerin eine verständliche Meldung (503 bzw. 422), statt eines Stacktraces.

Getestet ohne Galaxus

Die Feature-Tests laufen komplett ohne Netzwerk: Mit Http::fake() simulieren wir den MCP-Handshake, JSON- und SSE-Antworten, abgelaufene Sessions, den Token-Refresh nach 401 und den Bild-Download. So können wir auch Randfälle zuverlässig prüfen, die in der Realität selten auftreten.

Fazit

Das Warten auf eine offizielle Schnittstelle hat sich gelohnt. Mit dem Galaxus-MCP haben wir statt eines Scrapers, der nicht funktioniert hat, eine robuste Integration: Produkte direkt in Ginolino suchen, auswählen, und Bild, Name, Preis und Link sind im Geschenk. Für Nutzerinnen und Nutzer ist das ein spürbarer Komfortgewinn.

Für uns als Entwickler ist aber noch etwas anderes spannend: MCP-Server werden gerade zu einer neuen Art von öffentlicher API. Sie sind für KI-Assistenten gedacht, aber nichts hindert uns daran, sie direkt aus einer Webapplikation anzusprechen. Wer das Protokoll versteht, kann so Dienste integrieren, die bisher keine klassische Schnittstelle angeboten haben.

Ihr habt einen Dienst oder eine Datenquelle, die ihr in eure Anwendung integrieren möchtet, ob per API, MCP oder etwas ganz anderem? Meldet euch bei uns.