Skip to content

MCP-Server — Tools anbinden ​

Diese Seite erklaert, wie du dem mcp-Plugin ein neues Tool fuer den Model-Context-Protocol-Server hinzufuegst und es an die kanonische API verknuepfst. Der Server laesst LLM-Clients (Claude Code, Claude Desktop, Cursor) das CMS per HTTP mit einem Bearer-Token steuern. Ein Tool ist eine PHP-Methode mit typisierten Parametern (vom SDK in ein JSON-Schema uebersetzt), die ein Array zurueckgibt.

Die Grundregel: Ein Write-Tool baut die Modell-Logik nie nach. Es ruft den echten backend/*-Endpunkt ueber einen authentifizierten Loopback auf, damit Validierung, Draft-Handling, ID-Generierung und CSS-Recompile unveraendert laufen.

Verzeichnisstruktur ​

_public/extensions/core/backend/mcp/
├── bootstrap.php                       # mcp_BackendPlugin — registriert backend/mcp + backend/mcptokens
├── composer.json                       # plugin-lokal: mcp/sdk, PSR-4 Newmeta\Mcp\Tools\ → tools/
├── vendor/                             # plugin-lokale Composer-Installation (autoload.php)
├── api/
│   ├── backend/mcp/model.php           # backend_mcp — startet den SDK-Server, registriert jedes Tool
│   └── backend/mcptokens/model.php     # Token-CRUD + Scope-Whitelist (const SCOPES)
├── lib/
│   ├── McpAuth.php                     # Bearer-Token-Validierung + Service-Session-Kontext
│   ├── McpBridge.php                   # requireScope() / requireAnyScope() / audit() / baseLanguage()
│   └── McpHttpBridge.php               # call() / upload() — authentifizierter HTTP-Loopback an backend/*
└── tools/
    ├── PingTool.php                    # Health-Check (read)
    ├── DesignTools.php                 # Read-Tool (direktes query())
    ├── DesignWriteTools.php            # Write-Tool (Loopback an backend/design)
    ├── PagebuilderWriteTools.php       # Draft-First-Write-Tools
    ├── RedirectTools.php               # Direkter Write ueber backend/item
    ├── PublishTools.php                # Einzige Live-Schaltung von Seiten (publish-Scope)
    └── …                               # eine Klasse pro Tool-Gruppe

Das Composer-Setup ist plugin-lokal — eigenes composer.json und vendor/ — weil der CMS-Kern require_once-basiert ist. vendor/autoload.php wird im backend/mcp-Endpunkt geladen, nicht global.

Architektur in einem Diagramm ​

Der Endpunkt authentifiziert den Bearer-Token, setzt eine Backend-Service-Session, baut den SDK-Server mit allen registrierten Tools und dispatcht den JSON-RPC-Request via StreamableHttpTransport. Ein Write-Tool prueft einen Scope und ruft dann den kanonischen Endpunkt ueber einen Loopback auf, der dieselbe Session wiederverwendet:

LLM-Client (Claude / Cursor)
   │  POST /api/backend/mcp   Authorization: Bearer nscms_mcp_…
   ▼
backend_mcp::apiAction()                            (api/backend/mcp/model.php)
   ├─ McpAuth::authenticate()      → mcp_tokens (SHA-256), scopes, tenant_uuid
   │                                 setzt $_SESSION[backend_loggedin|backend_superadmin|mcp_session]
   ├─ require vendor/autoload.php   (plugin-lokales SDK)
   ├─ Mcp\Server::builder()->setServerInfo()->setSession(FileSessionStore)
   │     ->addTool([Tool::class,'method'], name:, description:) …  (eins pro Tool)
   └─ $server->run(StreamableHttpTransport)  → Dispatch an die getroffene Tool-Methode
          │
          ▼
   YourTool::yourMethod($typedArgs)            (tools/YourTool.php)
     ├─ McpBridge::requireScope('content')     ← Scope-Gate (wirft, wenn fehlend)
     ├─ McpHttpBridge::call('backend/x', …)    ← authentifizierter Loopback (gleicher Session-Cookie)
     │        │  curl https://{host}/api/backend/x  Cookie: PHPSESSID=…
     │        ▼
     │   backend_x::apiAction()  → kanonische Validierung / Draft / Recompile / insert_id()
     ├─ McpBridge::audit('mcp.tool.…')
     └─ return ['ok' => true, …]   (try/catch → ['ok' => false, 'error' => …])

Wichtige Eigenschaften des Endpunkts (backend_mcp):

PropertyWertZweck
$publicMethods['GET', 'POST', 'DELETE']Der SDK-Transport braucht alle drei; der Bearer-Token ist der eigentliche Gate
$skipOriginChecktrueEntfernte CLI-Clients haben keinen passenden Origin/Cookie — der Gate ist das Token, nicht der Origin

Der Bearer-Token ist der Gate, nicht der Origin

$skipOriginCheck = true laesst den Request durch apiBaseController. Die Autorisierung erfolgt nur ueber McpAuth::authenticate() plus den Per-Tool-Scope-Check. Ueberspringe requireScope() in einem Write-Tool nie mit dem Argument "der Endpunkt ist ohnehin geschuetzt" — ohne den Check koennte ein read-only-Token schreiben.

Scopes ​

McpAuth loest die Scopes des Tokens in den Service-Kontext auf. Tools pruefen sie ueber McpBridge. Die Whitelist liegt in api/backend/mcptokens/model.php als private const SCOPES:

ScopeErlaubt
readRead-Tools (Kataloge, Struktur, Settings, Media-Suche)
designsave_design_less (live)
contentPagebuilder-Draft-Writes, Widget-Content, Redirects, SEO-Meta, Settings
menuMenue-Item anlegen/aktualisieren
mediaMedia-Ordner/-Datei anlegen, umbenennen, hochladen
publishpublish_page — die einzige Live-Schaltung von Seiten

McpAuth::hasScope() behandelt einen *-Scope als "alle", ausser bei publish_page: das prueft bewusst den literalen publish-Scope und ignoriert *.

Loopback vs. direkte Query ​

Tool-ArtMechanismusBeispiel
Readquery() / fetch_assoc() direktDesignTools::getDesignTokens() liest website.custom_less
Write (dedizierter Endpunkt)McpHttpBridge::call($endpoint, $method, $payload, $query)DesignWriteTools → backend/design save_less
Write (content_construct=table)McpHttpBridge::call('backend/item', …)RedirectTools → backend/item mit {table, data} / {table, id, data}
Upload (Multipart)McpHttpBridge::upload(…)Media-Datei-Upload

McpHttpBridge::call() ruft zuerst session_write_close() auf, damit der Loopback-Request dieselbe backend_loggedin-Session lesen kann, und curlt dann https://{host}/api/{endpoint} mit dem aktuellen Session-Cookie. Bei einer Antwort >= 400 wirft er eine RuntimeException mit dem error des Endpunkts — kein Teil-Write erreicht den Client.

Draft-First ​

Pagebuilder-Writes sind Draft-First. pagebuilder_add_row ruft vor dem Editieren immer init_draft auf (idempotent — stellt eine published-Baseline sicher), sodass die Live-Seite unveraendert bleibt, bis ein Operator published. Nur publish_page schaltet einen Draft live, und es verlangt den literalen publish-Scope.

Ein neues Tool anbinden — Schritt fuer Schritt ​

Das Beispiel fuegt ein Tool set_page_noindex hinzu, das den noindex-SEO-Flag einer Seite ueber den kanonischen backend/domain-Endpunkt umschaltet (illustrativ — passe Endpunkt/Action an dein echtes Ziel an).

1. Tool-Klasse / Methode anlegen ​

Tool-Klassen liegen in tools/, Namespace Newmeta\Mcp\Tools (PSR-4, plugin-lokaler Autoloader). Jede Methode traegt ein #[McpTool(...)]-Attribut. Typisierte Parameter werden zum JSON-Schema, das das LLM sieht; der Rueckgabewert ist ein Array. Umschliesse den Body immer mit try/catch und gib bei einem Fehler ein strukturiertes {ok: false, error} zurueck.

php
<?php

namespace Newmeta\Mcp\Tools;

use Mcp\Capability\Attribute\McpTool;

/**
 * Write-Tool-Beispiel: noindex-Flag einer Seite umschalten (Draft, via Loopback).
 */
class NoindexTools
{
    #[McpTool(
        name: 'set_page_noindex',
        description: 'Set or clear the noindex SEO flag of a page (draft). page_id from list_pages; noindex=true hides the page from search engines once published.'
    )]
    public function setPageNoindex(int $page_id, bool $noindex): array
    {
        try {
            // 2a. Scope-Gate — wirft, wenn der Token den Scope nicht hat.
            \McpBridge::requireScope('content');

            if ($page_id <= 0) {
                throw new \RuntimeException('page_id is required.');
            }

            // 2b. Kanonische Logik via authentifizierten Loopback wiederverwenden.
            $res = \McpHttpBridge::call('backend/domain', 'PATCH', [
                'action'  => 'update_seo_meta',
                'page_id' => $page_id,
                'noindex' => $noindex ? 1 : 0,
                'lang'    => \McpBridge::baseLanguage(),
            ]);

            // 2c. Aufruf auditieren (best effort, INFO).
            \McpBridge::audit('mcp.tool.set_page_noindex', ['page_id' => $page_id]);

            return ['ok' => true, 'result' => $res];
        } catch (\Throwable $e) {
            // Strukturierter Fehler — sonst verschluckt das SDK die Meldung
            // hinter einem generischen JSON-RPC -32603.
            return ['ok' => false, 'error' => $e->getMessage(), 'error_class' => get_class($e)];
        }
    }
}

Praxis-Referenzen: tools/DesignWriteTools.php (einzelner Loopback-Write), tools/RedirectTools.php (gemeinsames guard() + backend/item), tools/PagebuilderWriteTools.php (Draft-First).

Gemeinsames guard() fuer Klassen mit mehreren Methoden

Wenn eine Klasse mehrere Write-Methoden mit demselben Scope hat, faktorisiere requireScope + audit + try/catch in einen privaten Helper guard(callable $fn) aus, wie es RedirectTools und PagebuilderWriteTools tun. Jede Methode gibt dann nur $this->guard(fn () => …) zurueck.

2. Loopback oder direkte Query waehlen ​

ZielSo gehst du vor
Write ueber einen dedizierten Endpunkt (Design, Pagebuilder, SEO)`McpHttpBridge::call('backend/{x}', 'POST'
Write eines content_construct=table-Datensatzes (Redirects, Settings)McpHttpBridge::call('backend/item', 'POST', ['table' => …, 'data' => …])
Readquery() / fetch_assoc() / fetch_all() direkt, Gate via requireAnyScope(['read', '{domain}'])
Datei hochladenMcpHttpBridge::upload($endpoint, $query, $fields, $fileField, $filePath, $fileName)

Triff die HTTP-Methode, die der Ziel-Endpunkt erwartet (backend/item nutzt POST zum Anlegen, PATCH zum Aktualisieren). Bei Modellen, die action aus $_GET lesen, uebergib sie ueber das $query-Argument, nicht im Payload.

3. Tool in model.php registrieren ​

Oeffne api/backend/mcp/model.php. Fuege ein require_once fuer die neue Klasse neben den anderen ein und haenge dann einen ->addTool(...)-Aufruf vor ->build() an die Kette:

php
// im require_once-Block (Schritt 5 von apiAction):
require_once $pluginRoot . '/tools/NoindexTools.php';

// in der Mcp\Server::builder()-Kette, vor ->build():
->addTool(
    [\Newmeta\Mcp\Tools\NoindexTools::class, 'setPageNoindex'],
    name: 'set_page_noindex',
    description: 'Set or clear the noindex SEO flag of a page (draft). page_id from list_pages; noindex=true hides the page from search engines once published.'
)

Registrierung ist explizit — Discovery ist aus

Der Server nutzt keine SDK-Auto-Discovery (setDiscovery() findet im plugin-lokalen Composer-Setup 0 Tools). Jedes Tool braucht sowohl ein require_once als auch einen ->addTool(...)-Eintrag. name und description an addTool sind das, was der Client sieht — halte sie mit dem #[McpTool]-Attribut konsistent.

4. Scope zur Whitelist hinzufuegen (falls neu) ​

Wenn dein Tool einen Scope einfuehrt, der noch nicht in der Liste steht, ergaenze ihn in private const SCOPES in api/backend/mcptokens/model.php, damit er beim Anlegen eines Tokens vergeben werden kann:

php
private const SCOPES = ['read', 'design', 'content', 'menu', 'media', 'publish'];

Die bestehenden sechs Scopes decken die meisten Faelle ab — verwende fuer Content-Writes lieber content wieder, als einen neuen Scope zu erfinden.

5. Deployen und neu verbinden ​

Der neue Code greift erst in einer frischen MCP-Session:

bash
# 1. Plugin-Dateien deployen (tools/*.php + model.php).
# 2. Wenn composer.json geaendert wurde, das plugin-lokale vendor installieren:
cd _public/extensions/core/backend/mcp && composer install --no-dev --optimize-autoloader
# 3. Den MCP-Client (Claude Code / Cursor) neu verbinden, damit er die Tool-Liste neu laedt.

Pruefe, dass das Tool verfuegbar ist, indem du ping aufrufst (gibt Tenant und gewaehrte Scopes zurueck), und rufe dann das neue Tool auf.

Warum strukturierte Fehler ​

Das SDK verpackt eine nicht gefangene Exception in einen generischen JSON-RPC--32603-Fehler und verbirgt die echte Meldung vor dem Client. Die Rueckgabe ['ok' => false, 'error' => $e->getMessage(), 'error_class' => get_class($e)] legt die tatsaechliche Ursache offen (fehlender Scope, Validierungsfehler, Endpunkt-Fehler), sodass das LLM — und der Entwickler, der das Transcript liest — darauf reagieren kann. Jedes Tool folgt diesem Muster.

Haeufige Fehler ​

->addTool(...) oder require_once vergessen

Ein Tool braucht sowohl eine require_once $pluginRoot . '/tools/…'-Zeile als auch einen ->addTool([Class::class, 'method'], …)-Eintrag in model.php. Auto-Discovery wird bewusst nicht genutzt — eine Klasse auf der Platte ohne addTool-Eintrag ist fuer Clients unsichtbar.

requireScope() in einem Write-Tool ueberspringen

$skipOriginCheck = true und die Service-Session passieren den backend_loggedin-Gate des kanonischen Modells, sodass ein Write selbst mit einem read-only-Token gelingen wuerde. Das Per-Tool-requireScope() / requireAnyScope() ist das einzige, was die Scope-Granularitaet durchsetzt. Gate jeden Write.

Modell-Logik nachbauen statt Loopback

Direkt aus einem Tool in page_row / website / redirects zu schreiben, umgeht Validierung, Draft-Handling, ID-Generierung und CSS-Recompile. Gehe immer ueber McpHttpBridge::call() an den kanonischen backend/*-Endpunkt (oder backend/item bei content_construct=table).

Werfen statt strukturiertem Fehler

Eine nicht gefangene Exception wird beim Client zu einem undurchsichtigen JSON-RPC--32603. Umschliesse den Body mit try/catch und gib ['ok' => false, 'error' => …] zurueck, damit die Meldung erhalten bleibt.

Publishen als Nebeneffekt

publish_page verlangt den literalen publish-Scope (ein *-Wildcard deckt ihn nicht ab) und darf nur auf ausdrueckliche Nutzer-Aufforderung aufgerufen werden — nie als Teil eines Bau-/Edit-Flows. Halte Seiten-Writes Draft-First.

Siehe auch ​