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-GruppeDas 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):
| Property | Wert | Zweck |
|---|---|---|
$publicMethods | ['GET', 'POST', 'DELETE'] | Der SDK-Transport braucht alle drei; der Bearer-Token ist der eigentliche Gate |
$skipOriginCheck | true | Entfernte 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:
| Scope | Erlaubt |
|---|---|
read | Read-Tools (Kataloge, Struktur, Settings, Media-Suche) |
design | save_design_less (live) |
content | Pagebuilder-Draft-Writes, Widget-Content, Redirects, SEO-Meta, Settings |
menu | Menue-Item anlegen/aktualisieren |
media | Media-Ordner/-Datei anlegen, umbenennen, hochladen |
publish | publish_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-Art | Mechanismus | Beispiel |
|---|---|---|
| Read | query() / fetch_assoc() direkt | DesignTools::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
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
| Ziel | So 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' => …]) |
| Read | query() / fetch_assoc() / fetch_all() direkt, Gate via requireAnyScope(['read', '{domain}']) |
| Datei hochladen | McpHttpBridge::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:
// 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:
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:
# 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
- Plugin-Anatomie —
install_controller,bootstrap.php, Registrierungs-Properties - API-Endpunkte —
$this->apiEndpoints, Pfad-zu-Klasse-Benennung,$publicMethods - Content Constructs — der
table-Construct hinterbackend/item - Webhook-Events — Audit- und Event-Dispatch-Muster