Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
403 KiB
Etappe 1a – WordPress-Basis: Inhalte, Menüs, Einstellungen – Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Claude kann über den OAuth-geschützten MCP-Server Seiten, Beiträge und Pods-Einträge generisch finden, lesen, anlegen und bearbeiten, ihren Status explizit steuern, Entwurfskopien anlegen und zurücktauschen, Revisionen nutzen, Menüs aufbauen und die freigegebenen WordPress-Einstellungen setzen – jede Änderung mit einheitlichem ToolResult und Protokolleintrag.
Architecture: Neue Integration WordPressIntegration (immer verfügbar) liefert die Abilities; eine ContentSchema führt Feldquellen zusammen (Pods → registrierte Meta → Meta-Stichprobe), FieldValidator/TermAssigner/PostWriter validieren alles vor dem ersten Schreibzugriff (alles oder nichts). Querschnitt: Errors (JSON-Fehler mit hint), ToolResult (Spec §8.1), Cursor, Rev, ChangeLog (Tabelle {prefix}lit_mcp_log, täglicher Cron), RequestContext (Flag-Reset nach dem Request, Client-Name für das Protokoll). Etappe 1b (separater Plan) ergänzt Medien, Einstellungsseite, Expositions-Policy, Hinweise und Aufräumen.
Tech Stack: PHP ≥ 8.1, WordPress 7.0 Abilities API, wordpress/mcp-adapter 0.5.0, wp-media/mcp-oauth 1.1.2, Pods 3.3.9 (optional), PHPUnit 9.6 + Brain Monkey + Mockery, phpcs (PSR-12 + WordPress-Security), DDEV.
Spec: docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md (insb. §2, §4.3–4.5, §6.1, §6.2, §6.4, §6.5, §8.1–8.3, §9, §10, §11.3, §12 Etappe 1)
Folgeplan: docs/superpowers/plans/2026-09-25-etappe-1b-medien-admin.md (Medien §6.3, Einstellungsseite §8.4, Expositions-Policy §4.3, Aufräumen, Hinweise). 1b setzt diesen Plan voraus.
Global Constraints
- Dateien ausschließlich im Plugin-Verzeichnis
/home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCPanlegen oder ändern. Das installierte, inaktive Pods darf in DDEV für Integrationstests aktiviert werden (danach wieder deaktivieren); keine neuen Plugins installieren. - PHP ≥ 8.1, WordPress ≥ 6.9 (getestet mit 7.0.3); Plugin-Header
Tested up to: 7.0. - Namespace
LIT\MCP\→src/, PSR-4, eine Klasse pro Datei, PSR-12, höchstens 120 Zeichen pro Zeile;composer lintundcomposer testmüssen nach jedem Task sauber sein. - Unit-Tests: PHPUnit 9.6 + Brain Monkey + Mockery über
LIT\MCP\Tests\Unit\TestCase; Stubs für WP-Klassen intests/Stubs/(intests/bootstrap.phpeingebunden). Funktionen, dieTestCase::setUp()perFunctions\stubs()vorbelegt (wp_json_encode,is_wp_error,absint,wp_slash,wp_unslash,sanitize_text_field), nie mitFunctions\expect()prüfen – Brain Monkey verbietet Stub und Erwartung auf derselben Funktion. - Integrationstests laufen im DDEV-Container:
cd /home/janniclas/Projekte/WordPress && timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/<skript>.sh; WP-CLI übertimeout 120 ddev wp …. - Ability-Namen
lit/<bereich>-<aktion>→ Tool-Namenlit-<bereich>-<aktion>. Annotations exakt: Lese-Toolsreadonly: true;destructive: truenur für Löschen, Ersetzen, Austausch (Papierkorb, Kopie-Austausch, Revision wiederherstellen, Menübaum ersetzen);idempotentwie je Task angegeben. - Input- und Output-Schemas als JSON Schema 2020-12, nur mit Schlüsselwörtern, die
rest_validate_value_from_schema()prüft (typeinkl. Typ-Listen,properties,required,items,enum,minimum,maximum,minLength,maxLength,pattern,minItems,maxItems,additionalProperties). WordPress prüft Eingaben vorexecute()und Ausgaben danach – eine Ausgabe, die nicht zumoutputSchemapasst, wird zum Fehler. - Fachliche Fehler ausschließlich über
LIT\MCP\Abilities\Errors(Codelit_<code>, Nachricht = JSON{code, message, hint, …}), nie Stacktraces. - Schreib-Tools ändern nie den Veröffentlichungsstatus; neue Einträge immer als Entwurf; Beiträge nur in den Papierkorb, nie endgültig löschen (einzige Ausnahme: Menüeinträge
nav_menu_item, die WordPress selbst ohne Papierkorb löscht – Task 14). - Tools mit
destructive: trueverlangenconfirm: true, sonstlit_confirm_requiredohne jede Änderung. - Jede erfolgreiche Änderung schreibt einen Protokolleintrag (
ChangeLog::record) und liefertToolResultnach Spec §8.1;revision_idist der Wiederherstellungspunkt vor der Änderung (odernull),revder Hash despost_contentdanach (odernullbei Nicht-Beiträgen). - Listen mit Cursor-Paginierung:
limit1–100 (Standard 20),cursorrein,next_cursor(odernull) raus. - Keine Divi-Tools in dieser Etappe; Divi-Inhalte werden erkannt (
_et_pb_use_builder = onoder<!-- wp:divi/im Inhalt) und nur per Texthinweis auflit-divi-outlineverwiesen;post_contentvon Divi-Seiten wird nicht überschrieben. - WP-Einstellungen ausschließlich über die Whitelist aus Spec §6.5;
admin_email,users_can_register,default_role,siteurl,homesind gesperrt. - Commits:
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit …mit den TrailernCo-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>undClaude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7.
Review Focus
- Veröffentlichte Seite wird per
lit-content-updategeändert: Status bleibtpublish, vorher existiert ein Wiederherstellungspunkt (revision_id) → Test in Task 9 (wp_update_postohnepost_status,RevisionPointvor dem Schreiben). - Divi-Seite +
contentim Update: Rohtext-Überschreiben wird mitlit_unsupportedabgelehnt, nichts geschrieben; Titel/Felder ohnecontentgehen weiter → Test in Task 9. - Pods-Pflichtfeld fehlt beim Anlegen oder wird beim Update geleert, Auswahlwert ungültig, Beziehung auf falschen Typ:
lit_validation_failedmitfields.<key>, kein Eintrag entsteht → Tests in Task 7 und Task 9. - Zeitpunkte: Planen in der Vergangenheit oder im falschen Format wird abgelehnt; Veröffentlichen eines geplanten oder zukunftsdatierten Entwurfs setzt das Datum auf „jetzt“, statt still wieder
futurezu werden → Tests in Task 10. - Menü-Gesamtbaum ohne die bestehenden Einträge (oder
remove-Operation): ohneconfirm: true→lit_confirm_requiredmit Liste der betroffenen Einträge, nichts geschrieben; Antwort enthält immerprevious_itemszum Zurückbauen → Test in Task 14.
File Structure
wp-lit-mcp.php Header (Tested up to), Aktivierungs-/Deaktivierungs-Hooks
src/
Plugin.php verdrahtet ChangeLog, Schema, WordPressIntegration
Limits.php Option lit_mcp_limits (Upload-MB, Protokoll-Tage, Snapshots)
Lifecycle.php Aktivierung (Tabelle, Cron), Deaktivierung (Cron)
Abilities/
Errors.php (geändert) JSON-Fehler mit hint, neue Codes
ToolResult.php einheitliche Rückmeldung §8.1 + Schema
Cursor.php Cursor-Paginierung
Rev.php Inhalts-Hash für die optimistische Sperre
JsonMap.php leere Maps als {} statt []
ChangeLog/
ChangeLog.php Tabelle lit_mcp_log: record, purgeOlderThan
ChangeLogInstaller.php dbDelta, Schema-Version
ChangeLogCleanup.php täglicher Cron
Guides/InstructionsBuilder.php (geändert) Inhaltstypen, rev-Regel
Server/RequestContext.php (geändert) Reset nach Request, Client-Name
Integrations/
WordPress/
WordPressIntegration.php liefert alle Abilities dieses Plans
PostGuard.php lädt Eintrag + Rechteprüfung
RevisionPoint.php Wiederherstellungspunkt vor Änderungen
Schema/
FieldDefinition.php Wertobjekt eines Feldes
FieldSource.php Interface: Felder beschreiben, lesen, schreiben
MetaFieldSource.php Basis: Lesen/Schreiben über die Meta-API
RegisteredMetaSource.php get_registered_meta_keys()
SampledMetaSource.php Meta-Schlüssel-Stichprobe
ContentSchema.php Post-Typen, Taxonomien, zusammengeführte Felder
FieldValidator.php Feldwerte gegen Schema prüfen
TermAssigner.php Begriffe auflösen und zuweisen
Content/
PostPresenter.php Ausgabeformat eines Eintrags
PostWriter.php gemeinsame Validierung/Schreiblogik create/update
ContentTypesAbility.php lit/content-types
ContentQueryAbility.php lit/content-query
ContentGetAbility.php lit/content-get
ContentCreateAbility.php lit/content-create
ContentUpdateAbility.php lit/content-update
Status/
PostPublishAbility.php lit/post-publish
PostUnpublishAbility.php lit/post-unpublish
PostScheduleAbility.php lit/post-schedule
PostTrashAbility.php lit/post-trash
PostCloner.php Kopie anlegen / Kopie ins Original übernehmen
PostDuplicateAbility.php lit/post-duplicate
PostReplaceWithCopyAbility.php lit/post-replace-with-copy
Revisions/
RevisionsListAbility.php lit/revisions-list
RevisionRestoreAbility.php lit/revision-restore
Menus/
MenuTree.php Menüeinträge → Baum
MenuLocations.php Positionen zuweisen
MenuOperations.php Einzeloperationen → Zielbaum
MenuWriter.php Zielbaum prüfen und schreiben
MenuListAbility.php lit/menu-list
MenuGetAbility.php lit/menu-get
MenuCreateAbility.php lit/menu-create
MenuSetItemsAbility.php lit/menu-set-items
MenuAssignLocationAbility.php lit/menu-assign-location
Settings/
SettingsWhitelist.php Whitelist §6.5 inkl. Validierung
WpSettingsGetAbility.php lit/wp-settings-get
WpSettingsUpdateAbility.php lit/wp-settings-update
Pods/
PodsFieldSource.php Pods-Felder: Definition, Lesen, Schreiben via Pods-API
docs/guides/overview.md (geändert) Werkzeugliste
docs/guides/content-types.md Anleitung Inhaltstypen/Felder
docs/guides/workflows.md Anleitung Website aufsetzen
tests/
bootstrap.php (geändert) weitere Stubs
Stubs/WpPost.php, WpTerm.php, Wpdb.php neue Minimal-Stubs
Stubs/WpRestRequest.php (geändert) Header
Unit/… Tests je Klasse (Pfade spiegeln src/)
Integration/pods-fixture.php Pods-Testtyp lit_person anlegen/entfernen
Integration/lib.sh gemeinsame Shell-Helfer (Token, tool-Aufruf)
Integration/wp-basis-smoke.sh Ende-zu-Ende-Test aller Tools dieses Plans
Task 1: Rückmeldungs-Grundlagen (Errors, ToolResult, Cursor, Rev, JsonMap) und Plugin-Header
Der Adapter (v0.5) reicht von einem WP_Error nur get_error_message() als Text an den Client weiter (ToolsHandler::create_error_result). Damit Claude trotzdem {code, message, hint, fields?} (Spec §8.2) bekommt, trägt die Fehlermeldung diese Struktur als JSON. Außerdem entsteht die einheitliche Erfolgsrückmeldung.
Files:
- Modify:
src/Abilities/Errors.php(komplett ersetzen),wp-lit-mcp.php(Header),tests/Unit/TestCase.php - Create:
src/Abilities/ToolResult.php,src/Abilities/Cursor.php,src/Abilities/Rev.php,src/Abilities/JsonMap.php - Test:
tests/Unit/Abilities/ErrorsTest.php,tests/Unit/Abilities/ToolResultTest.php,tests/Unit/Abilities/CursorTest.php,tests/Unit/Abilities/RevAndJsonMapTest.php
Interfaces:
-
Consumes: bestehendes
Errors::forbidden(string),Errors::invalidInput(string, array),Errors::notFound(string),Errors::internal()(Aufrufer inAbilityRegistrar,GuideAbilitybleiben unverändert gültig). -
Produces:
Errors::make(string $code, string $message, string $hint, int $status, array $details = []): WP_Error– Codelit_<code>, Nachricht JSON, Daten['status' => …, 'hint' => …] + $details.Errors::forbidden(string $message, string $hint = …),Errors::invalidInput(string $message, array $fields = [], string $hint = …),Errors::notFound(string $message, string $hint = …),Errors::revMismatch(),Errors::inUse(string $message, array $usages),Errors::confirmRequired(string $message, array $details = []),Errors::unsupported(string $message, string $hint),Errors::internal()– alleWP_Error.ToolResult::ok(string $summary): ToolResult; Builderchanged(string $id, string $type, string $action, string $label = ''),warning(string),warnings(list<string>),outline(?string),rev(?string),revisionId(?int),snapshotId(?string),logId(?int),urls(?string $previewUrl, ?string $editUrl),with(string $key, mixed $value)(jeweils: self);toArray(): array;ToolResult::schema(array $extraProperties = [], array $extraRequired = []): array.Cursor::encode(int $offset): string,Cursor::decode(?string $cursor): ?int(null = ungültig,''/null-Eingabe = 0),Cursor::limit(mixed $limit, int $default = 20): int,Cursor::page(array $rows, int $limit, int $offset): array{items: list<mixed>, next_cursor: ?string},Cursor::invalid(): WP_Error,Cursor::inputProperties(): array.Rev::of(string $content): string(12 Hex-Zeichen).JsonMap::of(array $map): array|\stdClass(leer →stdClass, damit JSON{}entsteht).
-
Step 1: TestCase um Standard-Stubs erweitern
tests/Unit/TestCase.php (komplett ersetzen):
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit;
use Brain\Monkey;
use Brain\Monkey\Functions;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use PHPUnit\Framework\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
use MockeryPHPUnitIntegration;
protected function setUp(): void
{
parent::setUp();
Monkey\setUp();
// Pure helpers with obvious behaviour. Never use Functions\expect() on these
// names in a test: Brain Monkey forbids a stub and an expectation for the
// same function. Functions\when() may still redefine them.
Functions\stubs([
'wp_json_encode' => static fn ($data, int $flags = 0) => json_encode($data, $flags),
'is_wp_error' => static fn ($thing): bool => $thing instanceof \WP_Error,
'absint' => static fn ($value): int => abs((int) $value),
'wp_slash' => static fn ($value) => $value,
'wp_unslash' => static fn ($value) => $value,
'sanitize_text_field' => static fn ($value): string => trim(strip_tags((string) $value)),
]);
}
protected function tearDown(): void
{
Monkey\tearDown();
parent::tearDown();
}
}
- Step 2: Failing Tests
tests/Unit/Abilities/ErrorsTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Abilities;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Tests\Unit\TestCase;
final class ErrorsTest extends TestCase
{
public function testMessageCarriesCodeMessageHintAndFieldsAsJson(): void
{
$error = Errors::invalidInput('Titel fehlt.', ['title' => 'Pflichtfeld']);
self::assertSame('lit_validation_failed', $error->get_error_code());
$payload = json_decode($error->get_error_message(), true);
self::assertSame('validation_failed', $payload['code']);
self::assertSame('Titel fehlt.', $payload['message']);
self::assertNotSame('', $payload['hint']);
self::assertSame(['title' => 'Pflichtfeld'], $payload['fields']);
self::assertSame(400, $error->get_error_data()['status']);
self::assertSame($payload['hint'], $error->get_error_data()['hint']);
}
public function testUmlautsStayReadableInTheMessage(): void
{
$error = Errors::forbidden('Dein Benutzerkonto ist für LIT MCP nicht freigegeben.');
self::assertStringContainsString('für LIT MCP nicht freigegeben', $error->get_error_message());
}
public function testInUseCarriesTheUsages(): void
{
$error = Errors::inUse('Bild wird verwendet.', [['post_id' => 7, 'where' => 'featured']]);
$payload = json_decode($error->get_error_message(), true);
self::assertSame('lit_in_use', $error->get_error_code());
self::assertSame(7, $payload['usages'][0]['post_id']);
}
public function testEveryFactoryUsesLitPrefixAndANonEmptyHint(): void
{
$errors = [
Errors::forbidden('x'),
Errors::notFound('x'),
Errors::revMismatch(),
Errors::confirmRequired('x'),
Errors::unsupported('x', 'y'),
Errors::internal(),
];
foreach ($errors as $error) {
self::assertStringStartsWith('lit_', $error->get_error_code());
self::assertNotSame('', json_decode($error->get_error_message(), true)['hint']);
}
}
}
tests/Unit/Abilities/ToolResultTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Abilities;
use InvalidArgumentException;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Tests\Unit\TestCase;
final class ToolResultTest extends TestCase
{
public function testDefaultsMatchTheSpecShape(): void
{
self::assertSame(
[
'ok' => true,
'summary' => 'Seite angelegt',
'changed' => [],
'outline' => null,
'rev' => null,
'warnings' => [],
'revision_id' => null,
'snapshot_id' => null,
'log_id' => null,
'preview_url' => null,
'edit_url' => null,
],
ToolResult::ok('Seite angelegt')->toArray()
);
}
public function testBuilderFillsAllFieldsAndExtras(): void
{
$result = ToolResult::ok('Aktualisiert')
->changed('12', 'page', 'updated', 'Startseite')
->warning('Bild ohne Alt-Text')
->warnings(['Zweite Warnung'])
->outline('Hero')
->rev('a1b2c3d4e5f6')
->revisionId(812)
->snapshotId('snap-1')
->logId(4711)
->urls('https://kunde.de/?p=12&preview=true', 'https://kunde.de/wp-admin/post.php?post=12&action=edit')
->with('previous', ['blogname' => 'Alt'])
->toArray();
self::assertSame([['id' => '12', 'type' => 'page', 'action' => 'updated', 'label' => 'Startseite']], $result['changed']);
self::assertSame(['Bild ohne Alt-Text', 'Zweite Warnung'], $result['warnings']);
self::assertSame('Hero', $result['outline']);
self::assertSame('a1b2c3d4e5f6', $result['rev']);
self::assertSame(812, $result['revision_id']);
self::assertSame('snap-1', $result['snapshot_id']);
self::assertSame(4711, $result['log_id']);
self::assertSame('https://kunde.de/?p=12&preview=true', $result['preview_url']);
self::assertSame(['blogname' => 'Alt'], $result['previous']);
}
public function testReservedKeysCannotBeOverwrittenByExtras(): void
{
$this->expectException(InvalidArgumentException::class);
ToolResult::ok('x')->with('ok', false);
}
public function testSchemaRequiresCoreKeysAndAcceptsExtraProperties(): void
{
$schema = ToolResult::schema(['previous' => ['type' => 'object']], ['previous']);
self::assertSame('object', $schema['type']);
self::assertContains('summary', $schema['required']);
self::assertContains('previous', $schema['required']);
self::assertSame(['integer', 'null'], $schema['properties']['revision_id']['type']);
self::assertSame(['type' => 'object'], $schema['properties']['previous']);
}
}
tests/Unit/Abilities/CursorTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Abilities;
use LIT\MCP\Abilities\Cursor;
use LIT\MCP\Tests\Unit\TestCase;
final class CursorTest extends TestCase
{
public function testRoundTrip(): void
{
self::assertSame(40, Cursor::decode(Cursor::encode(40)));
}
public function testMissingCursorStartsAtZero(): void
{
self::assertSame(0, Cursor::decode(null));
self::assertSame(0, Cursor::decode(''));
}
public function testTamperedCursorIsRejected(): void
{
self::assertNull(Cursor::decode('not-a-cursor'));
self::assertNull(Cursor::decode(base64_encode('o:-5')));
self::assertNull(Cursor::decode(base64_encode('o:12;DROP')));
}
public function testLimitIsClamped(): void
{
self::assertSame(20, Cursor::limit(null));
self::assertSame(1, Cursor::limit(0));
self::assertSame(100, Cursor::limit(500));
self::assertSame(7, Cursor::limit(7));
}
public function testPageDetectsMoreRows(): void
{
$page = Cursor::page([1, 2, 3], 2, 10);
self::assertSame([1, 2], $page['items']);
self::assertSame(12, Cursor::decode($page['next_cursor']));
self::assertNull(Cursor::page([1, 2], 2, 0)['next_cursor']);
}
public function testInvalidCursorErrorIsAValidationError(): void
{
self::assertSame('lit_validation_failed', Cursor::invalid()->get_error_code());
}
public function testInputPropertiesDescribeLimitAndCursor(): void
{
$properties = Cursor::inputProperties();
self::assertSame(100, $properties['limit']['maximum']);
self::assertSame('string', $properties['cursor']['type']);
}
}
tests/Unit/Abilities/RevAndJsonMapTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Abilities;
use LIT\MCP\Abilities\JsonMap;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Tests\Unit\TestCase;
use stdClass;
final class RevAndJsonMapTest extends TestCase
{
public function testRevIsShortDeterministicAndContentSensitive(): void
{
self::assertMatchesRegularExpression('/^[0-9a-f]{12}$/', Rev::of('abc'));
self::assertSame(Rev::of('abc'), Rev::of('abc'));
self::assertNotSame(Rev::of('abc'), Rev::of('abd'));
}
public function testEmptyMapBecomesJsonObject(): void
{
self::assertInstanceOf(stdClass::class, JsonMap::of([]));
self::assertSame('{}', json_encode(JsonMap::of([])));
self::assertSame(['a' => 1], JsonMap::of(['a' => 1]));
}
}
- Step 3: Run – muss fehlschlagen
Run: cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test -- --filter 'ErrorsTest|ToolResultTest|CursorTest|RevAndJsonMapTest'
Expected: FAIL (Klassen ToolResult, Cursor, Rev, JsonMap fehlen; ErrorsTest scheitert, weil die Nachricht kein JSON ist).
- Step 4: Implementierung
src/Abilities/Errors.php (komplett ersetzen):
<?php
declare(strict_types=1);
namespace LIT\MCP\Abilities;
use WP_Error;
/**
* Factory for the error objects abilities return.
*
* mcp-adapter 0.5 turns a WP_Error into a tool result with isError: true but
* forwards only the message text. The message therefore carries the whole
* structured error as JSON: {code, message, hint, fields?, usages?, …} (spec §8.2).
*/
final class Errors
{
/**
* @param array<string, mixed> $details Extra keys for the client, e.g. fields or usages.
*/
public static function make(string $code, string $message, string $hint, int $status, array $details = []): WP_Error
{
$payload = array_merge(['code' => $code, 'message' => $message, 'hint' => $hint], $details);
$json = wp_json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return new WP_Error(
'lit_' . $code,
is_string($json) ? $json : $message,
array_merge(['status' => $status, 'hint' => $hint], $details)
);
}
public static function forbidden(
string $message,
string $hint = 'Mit den Rechten des verbundenen WordPress-Kontos nicht erlaubt; ggf. Administrator fragen.'
): WP_Error {
return self::make('forbidden', $message, $hint, 403);
}
/**
* @param array<string, string> $fields Input path => problem.
*/
public static function invalidInput(
string $message,
array $fields = [],
string $hint = 'Die unter fields genannten Eingaben korrigieren und erneut senden.'
): WP_Error {
return self::make('validation_failed', $message, $hint, 400, $fields === [] ? [] : ['fields' => $fields]);
}
public static function notFound(
string $message,
string $hint = 'ID prüfen, z. B. über lit-content-query oder lit-media-search suchen.'
): WP_Error {
return self::make('not_found', $message, $hint, 404);
}
public static function revMismatch(): WP_Error
{
return self::make(
'rev_mismatch',
'Der Inhalt wurde seit dem letzten Lesen geändert; nichts wurde geschrieben.',
'Neu lesen (z. B. lit-content-get) und die Änderung auf dem aktuellen Stand wiederholen.',
409
);
}
/**
* @param list<array<string, mixed>> $usages
*/
public static function inUse(string $message, array $usages): WP_Error
{
return self::make(
'in_use',
$message,
'Verwendungen prüfen; nur mit force: true trotzdem ausführen.',
409,
['usages' => $usages]
);
}
/**
* @param array<string, mixed> $details
*/
public static function confirmRequired(string $message, array $details = []): WP_Error
{
return self::make(
'confirm_required',
$message,
'Beim Nutzer nachfragen und dann mit confirm: true erneut aufrufen.',
409,
$details
);
}
public static function unsupported(string $message, string $hint): WP_Error
{
return self::make('unsupported', $message, $hint, 400);
}
public static function internal(): WP_Error
{
return self::make(
'internal_error',
'Interner Fehler bei der Ausführung. Details stehen im PHP-Error-Log der Website.',
'Später erneut versuchen oder den Administrator der Website informieren.',
500
);
}
}
src/Abilities/ToolResult.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Abilities;
use InvalidArgumentException;
/**
* Uniform success result of every writing ability (spec §8.1).
* The adapter returns it as structuredContent plus JSON text.
*/
final class ToolResult
{
private const RESERVED = [
'ok', 'summary', 'changed', 'outline', 'rev', 'warnings',
'revision_id', 'snapshot_id', 'log_id', 'preview_url', 'edit_url',
];
/** @var list<array{id: string, type: string, action: string, label: string}> */
private array $changed = [];
/** @var list<string> */
private array $warnings = [];
private ?string $outline = null;
private ?string $rev = null;
private ?int $revisionId = null;
private ?string $snapshotId = null;
private ?int $logId = null;
private ?string $previewUrl = null;
private ?string $editUrl = null;
/** @var array<string, mixed> */
private array $extra = [];
private function __construct(private string $summary)
{
}
public static function ok(string $summary): self
{
return new self($summary);
}
public function changed(string $id, string $type, string $action, string $label = ''): self
{
$this->changed[] = ['id' => $id, 'type' => $type, 'action' => $action, 'label' => $label];
return $this;
}
public function warning(string $warning): self
{
$this->warnings[] = $warning;
return $this;
}
/** @param list<string> $warnings */
public function warnings(array $warnings): self
{
foreach ($warnings as $warning) {
$this->warning($warning);
}
return $this;
}
public function outline(?string $outline): self
{
$this->outline = $outline;
return $this;
}
public function rev(?string $rev): self
{
$this->rev = $rev;
return $this;
}
public function revisionId(?int $revisionId): self
{
$this->revisionId = $revisionId;
return $this;
}
public function snapshotId(?string $snapshotId): self
{
$this->snapshotId = $snapshotId;
return $this;
}
public function logId(?int $logId): self
{
$this->logId = $logId;
return $this;
}
public function urls(?string $previewUrl, ?string $editUrl): self
{
$this->previewUrl = $previewUrl === '' ? null : $previewUrl;
$this->editUrl = $editUrl === '' ? null : $editUrl;
return $this;
}
public function with(string $key, mixed $value): self
{
if (in_array($key, self::RESERVED, true)) {
throw new InvalidArgumentException(sprintf('ToolResult key "%s" is reserved.', $key));
}
$this->extra[$key] = $value;
return $this;
}
/** @return array<string, mixed> */
public function toArray(): array
{
return array_merge(
[
'ok' => true,
'summary' => $this->summary,
'changed' => $this->changed,
'outline' => $this->outline,
'rev' => $this->rev,
'warnings' => $this->warnings,
'revision_id' => $this->revisionId,
'snapshot_id' => $this->snapshotId,
'log_id' => $this->logId,
'preview_url' => $this->previewUrl,
'edit_url' => $this->editUrl,
],
$this->extra
);
}
/**
* @param array<string, array<string, mixed>> $extraProperties
* @param list<string> $extraRequired
* @return array<string, mixed>
*/
public static function schema(array $extraProperties = [], array $extraRequired = []): array
{
return [
'type' => 'object',
'properties' => array_merge(
[
'ok' => ['type' => 'boolean'],
'summary' => ['type' => 'string'],
'changed' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'string'],
'type' => ['type' => 'string'],
'action' => ['type' => 'string'],
'label' => ['type' => 'string'],
],
'required' => ['id', 'type', 'action', 'label'],
],
],
'outline' => ['type' => ['string', 'null']],
'rev' => ['type' => ['string', 'null']],
'warnings' => ['type' => 'array', 'items' => ['type' => 'string']],
'revision_id' => ['type' => ['integer', 'null']],
'snapshot_id' => ['type' => ['string', 'null']],
'log_id' => ['type' => ['integer', 'null']],
'preview_url' => ['type' => ['string', 'null']],
'edit_url' => ['type' => ['string', 'null']],
],
$extraProperties
),
'required' => array_merge(
['ok', 'summary', 'changed', 'warnings', 'rev', 'revision_id', 'log_id'],
$extraRequired
),
];
}
}
src/Abilities/Cursor.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Abilities;
use WP_Error;
/**
* Opaque offset cursor for list tools (spec §8.1, §11.3).
*/
final class Cursor
{
public const DEFAULT_LIMIT = 20;
public const MAX_LIMIT = 100;
public static function encode(int $offset): string
{
return rtrim(strtr(base64_encode('o:' . $offset), '+/', '-_'), '=');
}
/** @return int|null Offset, 0 for a missing cursor, null for an invalid one. */
public static function decode(?string $cursor): ?int
{
if ($cursor === null || $cursor === '') {
return 0;
}
$raw = base64_decode(strtr($cursor, '-_', '+/'), true);
if ($raw === false || preg_match('/^o:(\d{1,9})$/', $raw, $matches) !== 1) {
return null;
}
return (int) $matches[1];
}
public static function limit(mixed $limit, int $default = self::DEFAULT_LIMIT): int
{
if ($limit === null) {
return $default;
}
return max(1, min(self::MAX_LIMIT, (int) $limit));
}
/**
* @param list<mixed> $rows Rows fetched with $limit + 1.
* @return array{items: list<mixed>, next_cursor: ?string}
*/
public static function page(array $rows, int $limit, int $offset): array
{
return [
'items' => array_slice($rows, 0, $limit),
'next_cursor' => count($rows) > $limit ? self::encode($offset + $limit) : null,
];
}
public static function invalid(): WP_Error
{
return Errors::invalidInput(
'Ungültiger cursor.',
['cursor' => 'unbekanntes Format'],
'cursor unverändert aus next_cursor der letzten Antwort übernehmen oder weglassen.'
);
}
/** @return array<string, array<string, mixed>> */
public static function inputProperties(): array
{
return [
'limit' => [
'type' => 'integer',
'minimum' => 1,
'maximum' => self::MAX_LIMIT,
'description' => 'Einträge pro Seite (Standard 20).',
],
'cursor' => [
'type' => 'string',
'description' => 'next_cursor der vorherigen Antwort, um weiterzublättern.',
],
];
}
}
src/Abilities/Rev.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Abilities;
/**
* Short hash of post_content – the optimistic-lock handle ("rev", spec §5.3).
*/
final class Rev
{
public static function of(string $content): string
{
return substr(hash('sha256', $content), 0, 12);
}
}
src/Abilities/JsonMap.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Abilities;
use stdClass;
/**
* PHP encodes an empty array as JSON [] – for schema type "object" the client
* must see {}. WordPress' output validation accepts stdClass for objects.
*/
final class JsonMap
{
/**
* @param array<string, mixed> $map
* @return array<string, mixed>|stdClass
*/
public static function of(array $map): array|stdClass
{
return $map === [] ? new stdClass() : $map;
}
}
wp-lit-mcp.php: im Header-Kommentar direkt unter * Requires PHP: 8.1 die Zeile * Tested up to: 7.0 einfügen, sonst nichts ändern.
- Step 5: Run – muss bestehen (inkl. aller bisherigen Tests)
Run: composer test && composer lint
Expected: alle Tests PASS (auch AbilityRegistrarTest, GuideAbilityTest – sie prüfen nur Code bzw. enthaltene Texte), phpcs ohne Fehler.
- Step 6: Commit
git add src/Abilities tests/Unit/TestCase.php tests/Unit/Abilities wp-lit-mcp.php
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: structured errors with hint, ToolResult, cursor pagination
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
Task 2: RequestContext – Reset nach dem Request und Client-Name
Aus Etappe 0 offen (Ruling 11): Das Flag „läuft gerade unser OAuth-Request“ wird nie zurückgesetzt – in persistenten PHP-Laufzeiten oder bei rest_do_request() könnte ein späterer Aufruf im selben Prozess die Routen-Prüfung passieren. Zusätzlich braucht das Protokoll (Spec §8.3, Spalte client) den Namen des verbundenen OAuth-Clients; mcp-oauth speichert ihn als Namen des Application Passwords, dessen UUID im JWT (app_pass_id) steht.
Files:
- Modify:
src/Server/RequestContext.php(komplett ersetzen),tests/Stubs/WpRestRequest.php(komplett ersetzen) - Test:
tests/Unit/Server/RequestContextTest.php(komplett ersetzen)
Interfaces:
-
Consumes:
ServerRegistrar::NAMESPACE,ServerRegistrar::ROUTE;WPMedia\MCP\OAuth\Auth\JWT::decode(string $token, string $secret): ?array,WPMedia\MCP\OAuth\Auth\SecretManager::get_secret(): string,WP_Application_Passwords::get_user_application_password(int $userId, string $uuid): ?array. -
Produces:
RequestContext::register()hängtrecordRouteanrest_request_before_callbacksundclearRouteanrest_request_after_callbacks(je Priorität 10, 3 Argumente).RequestContext::clearRoute(mixed $response, array $handler, WP_REST_Request $request): mixed– setzt alles zurück, wenn die Route unsere ist; gibt$responseunverändert zurück.RequestContext::clientName(): string– Name des Application Passwords, sonstclient_idaus dem Token, sonst''.RequestContext::reset(bool $isOAuthServerRequest = false, ?string $clientName = null): void(Test-Hilfe; zweiter Parameter neu).
-
Step 1: Stub für Request-Header erweitern
tests/Stubs/WpRestRequest.php (komplett ersetzen):
<?php
declare(strict_types=1);
if (! class_exists('WP_REST_Request')) {
class WP_REST_Request
{
/** @param array<string, string> $headers Lower-case header name => value. */
public function __construct(private string $route = '', private array $headers = [])
{
}
public function get_route(): string
{
return $this->route;
}
public function get_header(string $key): ?string
{
return $this->headers[strtolower($key)] ?? null;
}
}
}
- Step 2: Failing Tests
tests/Unit/Server/RequestContextTest.php (komplett ersetzen):
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Server;
use Brain\Monkey\Functions;
use LIT\MCP\Server\RequestContext;
use LIT\MCP\Tests\Unit\TestCase;
use stdClass;
use WP_REST_Request;
use WPMedia\MCP\OAuth\Auth\JWT;
final class RequestContextTest extends TestCase
{
private const ROUTE = '/mcp/mcp-oauth-server';
protected function tearDown(): void
{
RequestContext::reset();
parent::tearDown();
}
public function testIsOAuthServerRequestIsFalseByDefault(): void
{
self::assertFalse(RequestContext::isOAuthServerRequest());
}
public function testRecordRouteMarksTheOAuthServerRoute(): void
{
RequestContext::recordRoute(null, [], new WP_REST_Request(self::ROUTE));
self::assertTrue(RequestContext::isOAuthServerRequest());
}
public function testRecordRouteIgnoresOtherRoutes(): void
{
RequestContext::recordRoute(null, [], new WP_REST_Request('/wp/v2/users/me'));
self::assertFalse(RequestContext::isOAuthServerRequest());
}
public function testRecordRouteReturnsResponseUnchanged(): void
{
$response = new stdClass();
self::assertSame($response, RequestContext::recordRoute($response, [], new WP_REST_Request(self::ROUTE)));
}
public function testClearRouteResetsTheFlagAfterOurRequest(): void
{
$request = new WP_REST_Request(self::ROUTE);
RequestContext::recordRoute(null, [], $request);
$response = new stdClass();
$result = RequestContext::clearRoute($response, [], $request);
self::assertSame($response, $result);
self::assertFalse(RequestContext::isOAuthServerRequest());
}
public function testClearRouteIgnoresNestedRequestsToOtherRoutes(): void
{
RequestContext::recordRoute(null, [], new WP_REST_Request(self::ROUTE));
RequestContext::clearRoute(null, [], new WP_REST_Request('/wp/v2/posts'));
self::assertTrue(RequestContext::isOAuthServerRequest());
}
public function testClientNameFallsBackToTheClientIdClaim(): void
{
Functions\when('get_option')->justReturn('test-secret');
$token = JWT::encode([
'sub' => '3',
'app_pass_id' => 'uuid-unknown',
'client_id' => 'https://claude.ai/oauth/mcp-oauth-client-metadata',
'exp' => time() + 60,
], 'test-secret');
RequestContext::recordRoute(
null,
[],
new WP_REST_Request(self::ROUTE, ['authorization' => 'Bearer ' . $token])
);
self::assertSame('https://claude.ai/oauth/mcp-oauth-client-metadata', RequestContext::clientName());
}
public function testClientNameIsEmptyForAnInvalidToken(): void
{
Functions\when('get_option')->justReturn('test-secret');
RequestContext::recordRoute(null, [], new WP_REST_Request(self::ROUTE, ['authorization' => 'Bearer garbage']));
self::assertSame('', RequestContext::clientName());
}
public function testResetCanForceAClientName(): void
{
RequestContext::reset(true, 'Claude');
self::assertTrue(RequestContext::isOAuthServerRequest());
self::assertSame('Claude', RequestContext::clientName());
}
public function testRegisterHooksBeforeAndAfterCallbacks(): void
{
Functions\expect('add_filter')
->once()
->with('rest_request_before_callbacks', [RequestContext::class, 'recordRoute'], 10, 3);
Functions\expect('add_filter')
->once()
->with('rest_request_after_callbacks', [RequestContext::class, 'clearRoute'], 10, 3);
(new RequestContext())->register();
}
}
- Step 3: Run – muss fehlschlagen
Run: composer test -- --filter RequestContextTest
Expected: FAIL (clearRoute, clientName fehlen; reset() akzeptiert keinen zweiten Parameter).
- Step 4: Implementierung
src/Server/RequestContext.php (komplett ersetzen):
<?php
declare(strict_types=1);
namespace LIT\MCP\Server;
use Throwable;
use WP_REST_Request;
use WPMedia\MCP\OAuth\Auth\JWT;
use WPMedia\MCP\OAuth\Auth\SecretManager;
/**
* Tracks whether the current REST dispatch is serving our OAuth-protected MCP
* server route (`/mcp/mcp-oauth-server`) and which OAuth client made it.
*
* Abilities must never run outside that route. WordPress fires
* `rest_request_before_callbacks` for every matched REST route before any
* permission_callback runs and `rest_request_after_callbacks` right after the
* route callback returned (WP_REST_Server::respond_to_request()). The flag is
* set in the first and cleared in the second, so persistent PHP workers or a
* later `rest_do_request()` in the same process never inherit it.
*/
final class RequestContext
{
private static bool $isOAuthServerRequest = false;
private static ?string $bearerToken = null;
private static ?string $clientName = null;
public function register(): void
{
add_filter('rest_request_before_callbacks', [self::class, 'recordRoute'], 10, 3);
add_filter('rest_request_after_callbacks', [self::class, 'clearRoute'], 10, 3);
}
/**
* @param mixed $response
* @param array<string, mixed> $handler
* @return mixed
*/
public static function recordRoute(mixed $response, array $handler, WP_REST_Request $request): mixed
{
if ($request->get_route() === self::route()) {
self::$isOAuthServerRequest = true;
self::$clientName = null;
$header = $request->get_header('authorization');
self::$bearerToken = is_string($header) && str_starts_with($header, 'Bearer ')
? substr($header, 7)
: null;
}
return $response;
}
/**
* @param mixed $response
* @param array<string, mixed> $handler
* @return mixed
*/
public static function clearRoute(mixed $response, array $handler, WP_REST_Request $request): mixed
{
if ($request->get_route() === self::route()) {
self::reset();
}
return $response;
}
public static function isOAuthServerRequest(): bool
{
return self::$isOAuthServerRequest;
}
/**
* Name of the connected OAuth client (the Application Password mcp-oauth created
* for it), falling back to the token's client_id; '' when unknown.
*/
public static function clientName(): string
{
if (self::$clientName === null) {
self::$clientName = self::$bearerToken === null ? '' : self::resolveClientName(self::$bearerToken);
}
return self::$clientName;
}
/**
* Resets (or forces) the recorded state – after each request and between unit tests.
*/
public static function reset(bool $isOAuthServerRequest = false, ?string $clientName = null): void
{
self::$isOAuthServerRequest = $isOAuthServerRequest;
self::$bearerToken = null;
self::$clientName = $clientName;
}
private static function resolveClientName(string $token): string
{
try {
$claims = JWT::decode($token, SecretManager::get_secret());
if (! is_array($claims)) {
return '';
}
$userId = (int) ($claims['sub'] ?? 0);
$uuid = (string) ($claims['app_pass_id'] ?? '');
if ($userId > 0 && $uuid !== '' && class_exists('WP_Application_Passwords')) {
$item = \WP_Application_Passwords::get_user_application_password($userId, $uuid);
if (is_array($item) && is_string($item['name'] ?? null) && $item['name'] !== '') {
return $item['name'];
}
}
return (string) ($claims['client_id'] ?? '');
} catch (Throwable) {
return '';
}
}
private static function route(): string
{
return '/' . ServerRegistrar::NAMESPACE . '/' . ServerRegistrar::ROUTE;
}
}
- Step 5: Run – muss bestehen
Run: composer test && composer lint
Expected: PASS (auch AbilityRegistrarTest, das RequestContext::reset(true) nutzt).
- Step 6: Commit
git add src/Server/RequestContext.php tests/Stubs/WpRestRequest.php tests/Unit/Server/RequestContextTest.php
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "fix: reset request context after the MCP request, resolve client name
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
Task 3: Änderungsprotokoll, Limits, Aktivierung/Deaktivierung, Cron
Files:
- Create:
src/Limits.php,src/ChangeLog/ChangeLog.php,src/ChangeLog/ChangeLogInstaller.php,src/ChangeLog/ChangeLogCleanup.php,src/Lifecycle.php,tests/Stubs/Wpdb.php - Modify:
wp-lit-mcp.php(Hooks),src/Plugin.php(Boot),tests/bootstrap.php - Test:
tests/Unit/LimitsTest.php,tests/Unit/ChangeLog/ChangeLogTest.php,tests/Unit/ChangeLog/ChangeLogInstallerTest.php,tests/Unit/ChangeLog/ChangeLogCleanupTest.php,tests/Unit/LifecycleTest.php
Interfaces:
-
Consumes:
RequestContext::clientName()(Task 2). -
Produces:
Limits(final):OPTION = 'lit_mcp_limits',DEFAULTS = ['upload_mb' => 5, 'log_retention_days' => 90, 'snapshot_count' => 20];uploadBytes(): int,uploadMegabytes(): int,logRetentionDays(): int,snapshotCount(): int,all(): array{upload_mb: int, log_retention_days: int, snapshot_count: int},static sanitize(mixed $input): array(für die Settings API in 1b).ChangeLog(nicht final, mockbar):TABLE = 'lit_mcp_log';__construct(?\wpdb $db = null);tableName(): string;charsetCollate(): string;record(string $ability, string $objectType, string $objectId, string $summary, ?int $revisionId = null, ?string $snapshotId = null, string $result = 'ok'): ?int;purgeOlderThan(int $days): int.ChangeLogInstaller:VERSION_OPTION = 'lit_mcp_db_version',VERSION = '1';install(): void,maybeInstall(): void.ChangeLogCleanup:HOOK = 'lit_mcp_log_cleanup';register(): void,ensureScheduled(): void,run(): void.Lifecycle::activate(): void,Lifecycle::deactivate(): void(statisch).- Stub
wpdbfür Unit-Tests (Eigenschaftenprefix,posts,postmeta,usermeta,options,insert_id; echteprepare()/esc_like()), gemockt mitMockery::mock(\wpdb::class)->makePartial().
-
Step 1: wpdb-Stub
tests/Stubs/Wpdb.php:
<?php
declare(strict_types=1);
if (! class_exists('wpdb')) {
/**
* Minimal wpdb for unit tests. prepare()/esc_like() behave like the real ones
* closely enough for assertions on the generated SQL; query methods are meant
* to be mocked via Mockery::mock(wpdb::class)->makePartial().
*/
class wpdb
{
public $prefix = 'wp_';
public $posts = 'wp_posts';
public $postmeta = 'wp_postmeta';
public $usermeta = 'wp_usermeta';
public $options = 'wp_options';
public $insert_id = 0;
public function prepare($query, ...$args)
{
if (count($args) === 1 && is_array($args[0])) {
$args = $args[0];
}
// Quotes string placeholders but does not escape, so tests can assert on raw values.
$query = str_replace("'%s'", '%s', $query);
$query = str_replace('%s', "'%s'", $query);
return vsprintf($query, $args);
}
public function esc_like($text)
{
return addcslashes($text, '_%\\');
}
public function get_charset_collate()
{
return 'DEFAULT CHARACTER SET utf8mb4';
}
public function insert($table, $data, $format = null)
{
return 1;
}
public function query($query)
{
return 0;
}
public function get_col($query = null, $x = 0)
{
return [];
}
public function get_var($query = null, $x = 0, $y = 0)
{
return null;
}
public function get_results($query = null, $output = 'OBJECT')
{
return [];
}
}
}
tests/bootstrap.php (komplett ersetzen):
<?php
declare(strict_types=1);
if (! defined('ABSPATH')) {
define('ABSPATH', __DIR__ . '/');
}
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/Stubs/WpError.php';
require_once __DIR__ . '/Stubs/WpUser.php';
require_once __DIR__ . '/Stubs/WpRestRequest.php';
require_once __DIR__ . '/Stubs/Wpdb.php';
- Step 2: Failing Tests
tests/Unit/LimitsTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit;
use Brain\Monkey\Functions;
use LIT\MCP\Limits;
final class LimitsTest extends TestCase
{
public function testDefaultsWhenOptionIsMissing(): void
{
Functions\when('get_option')->justReturn([]);
$limits = new Limits();
self::assertSame(5 * 1024 * 1024, $limits->uploadBytes());
self::assertSame(5, $limits->uploadMegabytes());
self::assertSame(90, $limits->logRetentionDays());
self::assertSame(20, $limits->snapshotCount());
}
public function testStoredValuesAreClamped(): void
{
Functions\when('get_option')->justReturn(['upload_mb' => 500, 'log_retention_days' => 0, 'snapshot_count' => '7']);
self::assertSame(['upload_mb' => 100, 'log_retention_days' => 1, 'snapshot_count' => 7], (new Limits())->all());
}
public function testCorruptOptionFallsBackToDefaults(): void
{
Functions\when('get_option')->justReturn('garbage');
self::assertSame(Limits::DEFAULTS, (new Limits())->all());
}
public function testSanitizeReturnsACompleteClampedArray(): void
{
self::assertSame(
['upload_mb' => 12, 'log_retention_days' => 90, 'snapshot_count' => 100],
Limits::sanitize(['upload_mb' => '12', 'log_retention_days' => 'x', 'snapshot_count' => 1000])
);
self::assertSame(Limits::DEFAULTS, Limits::sanitize(null));
}
}
tests/Unit/ChangeLog/ChangeLogTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\ChangeLog;
use Brain\Monkey\Functions;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Server\RequestContext;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class ChangeLogTest extends TestCase
{
protected function tearDown(): void
{
RequestContext::reset();
parent::tearDown();
}
public function testRecordStoresReferencesAndReturnsTheId(): void
{
Functions\when('current_time')->justReturn('2026-09-25 08:00:00');
Functions\when('get_current_user_id')->justReturn(3);
RequestContext::reset(true, 'Claude');
$db = Mockery::mock(\wpdb::class)->makePartial();
$db->expects('insert')
->with('wp_lit_mcp_log', Mockery::on(static function (array $row): bool {
return $row['created_at'] === '2026-09-25 08:00:00'
&& $row['user_id'] === 3
&& $row['client'] === 'Claude'
&& $row['ability'] === 'lit/content-update'
&& $row['object_type'] === 'page'
&& $row['object_id'] === '12'
&& $row['summary'] === 'Seite geändert'
&& $row['revision_id'] === 811
&& $row['snapshot_id'] === null
&& $row['result'] === 'ok';
}), Mockery::type('array'))
->andReturnUsing(static function () use ($db): int {
$db->insert_id = 4711;
return 1;
});
$id = (new ChangeLog($db))->record('lit/content-update', 'page', '12', 'Seite geändert', 811);
self::assertSame(4711, $id);
}
public function testRecordReturnsNullWhenTheInsertFails(): void
{
Functions\when('current_time')->justReturn('2026-09-25 08:00:00');
Functions\when('get_current_user_id')->justReturn(3);
$db = Mockery::mock(\wpdb::class)->makePartial();
$db->allows('insert')->andReturn(false);
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $log);
try {
$id = (new ChangeLog($db))->record('lit/content-update', 'page', '12', 'x');
} finally {
ini_set('error_log', (string) $previous);
}
self::assertNull($id);
self::assertStringContainsString('change log insert failed', (string) file_get_contents($log));
unlink($log);
}
public function testPurgeDeletesRowsOlderThanTheRetention(): void
{
$db = Mockery::mock(\wpdb::class)->makePartial();
$db->expects('query')
->with(Mockery::on(static fn (string $sql): bool => str_starts_with(
$sql,
"DELETE FROM wp_lit_mcp_log WHERE created_at < '"
)))
->andReturn(3);
self::assertSame(3, (new ChangeLog($db))->purgeOlderThan(90));
}
}
tests/Unit/ChangeLog/ChangeLogInstallerTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\ChangeLog;
use Brain\Monkey\Functions;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\ChangeLog\ChangeLogInstaller;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class ChangeLogInstallerTest extends TestCase
{
public function testInstallCreatesTheTableAndStoresTheVersion(): void
{
$db = Mockery::mock(\wpdb::class)->makePartial();
Functions\expect('dbDelta')->once()->with(Mockery::on(static function (string $sql): bool {
return str_contains($sql, 'CREATE TABLE wp_lit_mcp_log (')
&& str_contains($sql, 'client varchar(191)')
&& str_contains($sql, 'revision_id bigint(20) unsigned DEFAULT NULL')
&& str_contains($sql, 'snapshot_id varchar(64) DEFAULT NULL')
&& str_contains($sql, 'PRIMARY KEY (id)');
}));
Functions\expect('update_option')->once()->with('lit_mcp_db_version', '1');
(new ChangeLogInstaller(new ChangeLog($db)))->install();
}
public function testMaybeInstallSkipsWhenTheVersionIsCurrent(): void
{
Functions\when('get_option')->justReturn('1');
Functions\expect('dbDelta')->never();
(new ChangeLogInstaller(new ChangeLog(Mockery::mock(\wpdb::class)->makePartial())))->maybeInstall();
}
}
tests/Unit/ChangeLog/ChangeLogCleanupTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\ChangeLog;
use Brain\Monkey\Functions;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\ChangeLog\ChangeLogCleanup;
use LIT\MCP\Limits;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class ChangeLogCleanupTest extends TestCase
{
public function testRunPurgesWithTheConfiguredRetention(): void
{
Functions\when('get_option')->justReturn(['log_retention_days' => 30]);
$log = Mockery::mock(ChangeLog::class);
$log->expects('purgeOlderThan')->with(30)->andReturn(0);
(new ChangeLogCleanup($log, new Limits()))->run();
}
public function testEnsureScheduledSchedulesADailyEventOnce(): void
{
Functions\when('wp_next_scheduled')->justReturn(false);
Functions\expect('wp_schedule_event')->once()->with(Mockery::type('int'), 'daily', 'lit_mcp_log_cleanup');
(new ChangeLogCleanup(Mockery::mock(ChangeLog::class), new Limits()))->ensureScheduled();
}
public function testEnsureScheduledDoesNothingWhenAlreadyScheduled(): void
{
Functions\when('wp_next_scheduled')->justReturn(1790330400);
Functions\expect('wp_schedule_event')->never();
(new ChangeLogCleanup(Mockery::mock(ChangeLog::class), new Limits()))->ensureScheduled();
}
}
tests/Unit/LifecycleTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit;
use Brain\Monkey\Functions;
use LIT\MCP\Lifecycle;
use Mockery;
final class LifecycleTest extends TestCase
{
protected function tearDown(): void
{
unset($GLOBALS['wpdb']);
parent::tearDown();
}
public function testActivateInstallsTheTableAndSchedulesTheCleanup(): void
{
$GLOBALS['wpdb'] = Mockery::mock(\wpdb::class)->makePartial();
Functions\expect('dbDelta')->once();
Functions\expect('update_option')->once()->with('lit_mcp_db_version', '1');
Functions\when('wp_next_scheduled')->justReturn(false);
Functions\expect('wp_schedule_event')->once();
Lifecycle::activate();
}
public function testDeactivateUnschedulesTheCleanup(): void
{
Functions\expect('wp_clear_scheduled_hook')->once()->with('lit_mcp_log_cleanup');
Lifecycle::deactivate();
}
}
- Step 3: Run – muss fehlschlagen
Run: composer test -- --filter 'LimitsTest|ChangeLog|LifecycleTest'
Expected: FAIL, Klassen nicht gefunden.
- Step 4: Implementierung
src/Limits.php:
<?php
declare(strict_types=1);
namespace LIT\MCP;
/**
* Admin-configurable limits (settings tab "Limits", spec §8.4).
*/
final class Limits
{
public const OPTION = 'lit_mcp_limits';
public const DEFAULTS = ['upload_mb' => 5, 'log_retention_days' => 90, 'snapshot_count' => 20];
private const BOUNDS = ['upload_mb' => [1, 100], 'log_retention_days' => [1, 3650], 'snapshot_count' => [1, 100]];
public function uploadBytes(): int
{
return $this->uploadMegabytes() * 1024 * 1024;
}
public function uploadMegabytes(): int
{
return $this->all()['upload_mb'];
}
public function logRetentionDays(): int
{
return $this->all()['log_retention_days'];
}
public function snapshotCount(): int
{
return $this->all()['snapshot_count'];
}
/** @return array{upload_mb: int, log_retention_days: int, snapshot_count: int} */
public function all(): array
{
return self::sanitize(get_option(self::OPTION, []));
}
/**
* Settings API sanitize callback: always returns all keys, clamped to their bounds.
*
* @return array{upload_mb: int, log_retention_days: int, snapshot_count: int}
*/
public static function sanitize(mixed $input): array
{
$input = is_array($input) ? $input : [];
$clean = self::DEFAULTS;
foreach (self::BOUNDS as $key => [$min, $max]) {
if (isset($input[$key]) && is_numeric($input[$key])) {
$clean[$key] = max($min, min($max, (int) $input[$key]));
}
}
return $clean;
}
}
src/ChangeLog/ChangeLog.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\ChangeLog;
use LIT\MCP\Server\RequestContext;
/**
* Change log table {prefix}lit_mcp_log (spec §8.3). Stores references only,
* never content. Deliberately not final so abilities can be unit-tested with a mock.
*/
class ChangeLog
{
public const TABLE = 'lit_mcp_log';
public function __construct(private ?\wpdb $db = null)
{
}
public function tableName(): string
{
return $this->db()->prefix . self::TABLE;
}
public function charsetCollate(): string
{
return (string) $this->db()->get_charset_collate();
}
public function record(
string $ability,
string $objectType,
string $objectId,
string $summary,
?int $revisionId = null,
?string $snapshotId = null,
string $result = 'ok'
): ?int {
$wpdb = $this->db();
$inserted = $wpdb->insert(
$this->tableName(),
[
'created_at' => current_time('mysql', true),
'user_id' => get_current_user_id(),
'client' => mb_substr(RequestContext::clientName(), 0, 191),
'ability' => $ability,
'object_type' => $objectType,
'object_id' => $objectId,
'summary' => mb_substr($summary, 0, 500),
'revision_id' => $revisionId,
'snapshot_id' => $snapshotId,
'result' => $result,
],
['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s']
);
if ($inserted === false) {
$message = sprintf('[wp-lit-mcp] change log insert failed for %s %s:%s', $ability, $objectType, $objectId);
error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
return null;
}
return (int) $wpdb->insert_id;
}
public function purgeOlderThan(int $days): int
{
$wpdb = $this->db();
$table = $this->tableName();
$cutoff = gmdate('Y-m-d H:i:s', time() - max(1, $days) * 86400);
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is our own constant.
return (int) $wpdb->query($wpdb->prepare("DELETE FROM {$table} WHERE created_at < %s", $cutoff));
}
protected function db(): \wpdb
{
if ($this->db === null) {
global $wpdb;
$this->db = $wpdb;
}
return $this->db;
}
}
src/ChangeLog/ChangeLogInstaller.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\ChangeLog;
/**
* Creates/updates the change log table via dbDelta. Runs on activation and –
* because plugin updates never fire the activation hook – whenever the stored
* schema version differs.
*/
final class ChangeLogInstaller
{
public const VERSION_OPTION = 'lit_mcp_db_version';
public const VERSION = '1';
public function __construct(private ChangeLog $log)
{
}
public function maybeInstall(): void
{
if (get_option(self::VERSION_OPTION) !== self::VERSION) {
$this->install();
}
}
public function install(): void
{
if (! function_exists('dbDelta')) {
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
}
$table = $this->log->tableName();
$charset = $this->log->charsetCollate();
dbDelta("CREATE TABLE {$table} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
created_at datetime NOT NULL,
user_id bigint(20) unsigned NOT NULL DEFAULT 0,
client varchar(191) NOT NULL DEFAULT '',
ability varchar(100) NOT NULL,
object_type varchar(50) NOT NULL DEFAULT '',
object_id varchar(191) NOT NULL DEFAULT '',
summary text NOT NULL,
revision_id bigint(20) unsigned DEFAULT NULL,
snapshot_id varchar(64) DEFAULT NULL,
result varchar(20) NOT NULL DEFAULT 'ok',
PRIMARY KEY (id),
KEY created_at (created_at),
KEY ability (ability),
KEY user_id (user_id)
) {$charset};");
update_option(self::VERSION_OPTION, self::VERSION);
}
}
src/ChangeLog/ChangeLogCleanup.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\ChangeLog;
use LIT\MCP\Limits;
/**
* Daily WP-Cron job that removes log rows older than the configured retention.
*/
final class ChangeLogCleanup
{
public const HOOK = 'lit_mcp_log_cleanup';
public function __construct(private ChangeLog $log, private Limits $limits)
{
}
public function register(): void
{
add_action(self::HOOK, [$this, 'run']);
add_action('init', [$this, 'ensureScheduled']);
}
public function ensureScheduled(): void
{
if (wp_next_scheduled(self::HOOK) === false) {
wp_schedule_event(time() + 3600, 'daily', self::HOOK);
}
}
public function run(): void
{
$this->log->purgeOlderThan($this->limits->logRetentionDays());
}
}
src/Lifecycle.php:
<?php
declare(strict_types=1);
namespace LIT\MCP;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\ChangeLog\ChangeLogCleanup;
use LIT\MCP\ChangeLog\ChangeLogInstaller;
/**
* Activation and deactivation hooks (registered in wp-lit-mcp.php).
*/
final class Lifecycle
{
public static function activate(): void
{
$log = new ChangeLog();
(new ChangeLogInstaller($log))->install();
(new ChangeLogCleanup($log, new Limits()))->ensureScheduled();
}
public static function deactivate(): void
{
wp_clear_scheduled_hook(ChangeLogCleanup::HOOK);
}
}
wp-lit-mcp.php: direkt nach require_once $lit_mcp_autoload; einfügen:
register_activation_hook(__FILE__, [\LIT\MCP\Lifecycle::class, 'activate']);
register_deactivation_hook(__FILE__, [\LIT\MCP\Lifecycle::class, 'deactivate']);
src/Plugin.php: in boot() direkt nach (new RequestContext())->register(); einfügen:
$changeLog = new ChangeLog();
(new ChangeLogInstaller($changeLog))->maybeInstall();
(new ChangeLogCleanup($changeLog, new Limits()))->register();
und oben die Imports ergänzen:
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\ChangeLog\ChangeLogCleanup;
use LIT\MCP\ChangeLog\ChangeLogInstaller;
($changeLog wird ab Task 6 an die WordPressIntegration übergeben.)
- Step 5: Run – muss bestehen
Run: composer test && composer lint
Expected: PASS.
- Step 6: Integration – Tabelle und Cron in DDEV
Run:
cd /home/janniclas/Projekte/WordPress
timeout 120 ddev wp plugin deactivate WP-LIT-MCP && timeout 120 ddev wp plugin activate WP-LIT-MCP
timeout 120 ddev wp db query "SHOW COLUMNS FROM wp_lit_mcp_log" --skip-column-names
timeout 120 ddev wp cron event list --hook=lit_mcp_log_cleanup --fields=hook,recurrence
Expected: elf Spalten (id … result); ein Cron-Eintrag lit_mcp_log_cleanup mit Wiederholung 1 day. (Tabellenpräfix der lokalen Installation ist wp_; falls ddev wp db prefix etwas anderes liefert, dieses verwenden.)
- Step 7: Commit
git add src/Limits.php src/Lifecycle.php src/ChangeLog src/Plugin.php wp-lit-mcp.php tests/Stubs/Wpdb.php tests/bootstrap.php tests/Unit/LimitsTest.php tests/Unit/LifecycleTest.php tests/Unit/ChangeLog
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: change log table with retention cron and limits option
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
Task 4: Schema-Kern (FieldDefinition, Feldquellen, ContentSchema)
Spec §6.1: Post-Typen mit show_ui, Taxonomien je Typ, Felder zusammengeführt aus registrierten Meta-Schlüsseln, Pods (Task 5) und einer Stichprobe vorhandener Meta-Schlüssel (_-Schlüssel ausgeblendet, sofern keine Quelle sie kennt). Status je Feld: writable, readonly, unknown.
Files:
- Create:
src/Integrations/WordPress/Schema/FieldDefinition.php,src/Integrations/WordPress/Schema/FieldSource.php,src/Integrations/WordPress/Schema/MetaFieldSource.php,src/Integrations/WordPress/Schema/RegisteredMetaSource.php,src/Integrations/WordPress/Schema/SampledMetaSource.php,src/Integrations/WordPress/Schema/ContentSchema.php - Test:
tests/Unit/Integrations/WordPress/Schema/RegisteredMetaSourceTest.php,tests/Unit/Integrations/WordPress/Schema/SampledMetaSourceTest.php,tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php
Interfaces:
-
Consumes: wpdb-Stub (Task 3).
-
Produces:
FieldDefinition(final, readonly-Properties):__construct(string $key, string $label, string $type, string $source, string $status, bool $required = false, bool $multiple = false, ?array $choices = null, ?array $related = null, string $description = ''); KonstantenWRITABLE,READONLY,UNKNOWN;toArray(): array{key, label, type, source, status, required, multiple, choices, related, description}. Typ-Vokabular:string,text,integer,number,boolean,date,datetime,time,choice,relation,file,array,object,unknown.choices:list<array{value: string, label: string}>|null;related:array{object_type: string, name: ?string}|null.FieldSource(Interface):id(): string;fieldsFor(string $postType): list<FieldDefinition>;read(int $postId, FieldDefinition $field): mixed;write(int $postId, string $postType, array $values, array $definitions): void($valueskey → Wert,null= leeren;$definitionskey → FieldDefinition; wirftRuntimeException).MetaFieldSource(abstrakt, implementiertread/writeüber die Meta-API),RegisteredMetaSource(idmeta),SampledMetaSource(idsample,__construct(?\wpdb $db = null)).ContentSchema(nicht final):__construct(list<FieldSource> $sources, ?callable $diviEnabled = null)(Quellen in Priorität, höchste zuerst);postTypeNames(): list<string>(sortiert, ohneEXCLUDED, Filterlit_mcp_excluded_post_types);isManaged(string $postType): bool;describe(string $postType, bool $withFields): ?array;describePostTypes(): list<array>;fields(string $postType): array<string, FieldDefinition>(nach Schlüssel sortiert, pro Request gecacht);sourceOf(FieldDefinition $field): FieldSource;taxonomies(string $postType): list<array{name: string, label: string, hierarchical: bool}>;diviEnabled(string $postType): bool.
-
Step 1: Failing Tests
tests/Unit/Integrations/WordPress/Schema/RegisteredMetaSourceTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Schema;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
use LIT\MCP\Integrations\WordPress\Schema\RegisteredMetaSource;
use LIT\MCP\Tests\Unit\TestCase;
final class RegisteredMetaSourceTest extends TestCase
{
/** @return array<string, array<string, mixed>> */
private function fieldsByKey(): array
{
Functions\when('get_registered_meta_keys')->alias(static function (string $type, string $sub = ''): array {
if ($sub === '') {
return ['footnotes' => ['type' => 'string', 'single' => true, 'show_in_rest' => true]];
}
return [
'_internal' => ['type' => 'integer', 'single' => true, 'show_in_rest' => false],
'tags_list' => ['type' => 'array', 'single' => false, 'description' => 'Liste'],
];
});
Functions\when('is_protected_meta')->alias(static fn (string $key): bool => str_starts_with($key, '_'));
$fields = (new RegisteredMetaSource())->fieldsFor('event');
return array_column(array_map(static fn (FieldDefinition $f): array => $f->toArray(), $fields), null, 'key');
}
public function testDescribesGlobalAndSubtypeMetaKeys(): void
{
$fields = $this->fieldsByKey();
self::assertSame(['footnotes', '_internal', 'tags_list'], array_keys($fields));
self::assertSame('string', $fields['footnotes']['type']);
self::assertSame(FieldDefinition::WRITABLE, $fields['footnotes']['status']);
self::assertSame('meta', $fields['footnotes']['source']);
self::assertFalse($fields['footnotes']['multiple']);
}
public function testProtectedMetaHiddenFromRestIsReadonly(): void
{
$fields = $this->fieldsByKey();
self::assertSame(FieldDefinition::READONLY, $fields['_internal']['status']);
self::assertSame('integer', $fields['_internal']['type']);
}
public function testNonSingleMetaIsMultiple(): void
{
$fields = $this->fieldsByKey();
self::assertTrue($fields['tags_list']['multiple']);
self::assertSame('Liste', $fields['tags_list']['description']);
}
public function testReadUsesSingleOrMultipleMeta(): void
{
Functions\expect('get_post_meta')->once()->with(5, 'footnotes', true)->andReturn('x');
Functions\expect('get_post_meta')->once()->with(5, 'tags_list', false)->andReturn(['a', 'b']);
$source = new RegisteredMetaSource();
self::assertSame('x', $source->read(5, new FieldDefinition('footnotes', 'footnotes', 'string', 'meta', 'writable')));
self::assertSame(
['a', 'b'],
$source->read(5, new FieldDefinition('tags_list', 'tags_list', 'array', 'meta', 'writable', false, true))
);
}
public function testWriteUpdatesReplacesListsAndDeletesNull(): void
{
$single = new FieldDefinition('footnotes', 'footnotes', 'string', 'meta', 'writable');
$multi = new FieldDefinition('tags_list', 'tags_list', 'array', 'meta', 'writable', false, true);
$gone = new FieldDefinition('old', 'old', 'string', 'meta', 'writable');
Functions\expect('update_post_meta')->once()->with(5, 'footnotes', 'neu');
Functions\expect('delete_post_meta')->once()->with(5, 'tags_list');
Functions\expect('add_post_meta')->once()->with(5, 'tags_list', 'a');
Functions\expect('add_post_meta')->once()->with(5, 'tags_list', 'b');
Functions\expect('delete_post_meta')->once()->with(5, 'old');
(new RegisteredMetaSource())->write(
5,
'event',
['footnotes' => 'neu', 'tags_list' => ['a', 'b'], 'old' => null],
['footnotes' => $single, 'tags_list' => $multi, 'old' => $gone]
);
}
}
tests/Unit/Integrations/WordPress/Schema/SampledMetaSourceTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Schema;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
use LIT\MCP\Integrations\WordPress\Schema\SampledMetaSource;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class SampledMetaSourceTest extends TestCase
{
public function testReturnsVisibleKeysOfRecentPostsAsUnknown(): void
{
$db = Mockery::mock(\wpdb::class)->makePartial();
$db->expects('get_col')
->with(Mockery::on(static fn (string $sql): bool => str_contains($sql, "post_type = 'lit_person'")
&& str_contains($sql, 'LIMIT 50')))
->andReturn(['_edit_lock', 'city', 'role']);
$fields = (new SampledMetaSource($db))->fieldsFor('lit_person');
self::assertSame(['city', 'role'], array_map(static fn (FieldDefinition $f): string => $f->key, $fields));
self::assertSame(FieldDefinition::UNKNOWN, $fields[0]->status);
self::assertSame('sample', $fields[0]->source);
self::assertSame('unknown', $fields[0]->type);
}
}
tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Schema;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
use LIT\MCP\Integrations\WordPress\Schema\FieldSource;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class ContentSchemaTest extends TestCase
{
private function source(string $id, array $fields): FieldSource
{
$source = Mockery::mock(FieldSource::class);
$source->allows('id')->andReturn($id);
$source->allows('fieldsFor')->andReturn($fields);
return $source;
}
private function stubPostTypes(): void
{
Functions\when('get_post_types')->justReturn([
'post' => 'post',
'page' => 'page',
'attachment' => 'attachment',
'et_pb_layout' => 'et_pb_layout',
'lit_person' => 'lit_person',
]);
}
public function testPostTypeNamesExcludeInternalTypesAndAreSorted(): void
{
$this->stubPostTypes();
$schema = new ContentSchema([]);
self::assertSame(['lit_person', 'page', 'post'], $schema->postTypeNames());
self::assertTrue($schema->isManaged('page'));
self::assertFalse($schema->isManaged('et_pb_layout'));
}
public function testFieldsAreMergedByPriorityAndSorted(): void
{
$pods = $this->source('pods', [new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable', true)]);
$meta = $this->source('sample', [
new FieldDefinition('role', 'role', 'unknown', 'sample', 'unknown'),
new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown'),
]);
$schema = new ContentSchema([$pods, $meta]);
$fields = $schema->fields('lit_person');
self::assertSame(['city', 'role'], array_keys($fields));
self::assertSame('pods', $fields['role']->source);
self::assertTrue($fields['role']->required);
self::assertSame($pods, $schema->sourceOf($fields['role']));
self::assertSame($meta, $schema->sourceOf($fields['city']));
}
public function testDescribeIncludesTaxonomiesCountsDiviAndOptionalFields(): void
{
$this->stubPostTypes();
Functions\when('get_post_type_object')->justReturn((object) [
'hierarchical' => true,
'labels' => (object) ['name' => 'Seiten', 'singular_name' => 'Seite'],
]);
Functions\when('get_all_post_type_supports')->justReturn(['title' => true, 'editor' => true]);
Functions\when('wp_count_posts')->justReturn((object) ['publish' => 3, 'draft' => 2, 'trash' => 9]);
Functions\when('get_object_taxonomies')->justReturn([
(object) ['name' => 'category', 'show_ui' => true, 'hierarchical' => true, 'labels' => (object) ['name' => 'Kategorien']],
(object) ['name' => 'post_format', 'show_ui' => false, 'hierarchical' => false, 'labels' => (object) ['name' => 'Formate']],
]);
$schema = new ContentSchema(
[$this->source('meta', [new FieldDefinition('footnotes', 'footnotes', 'string', 'meta', 'writable')])],
static fn (string $type): bool => $type === 'page'
);
$withoutFields = $schema->describe('page', false);
$withFields = $schema->describe('page', true);
self::assertSame('Seiten', $withoutFields['label']);
self::assertSame('Seite', $withoutFields['singular_label']);
self::assertTrue($withoutFields['hierarchical']);
self::assertSame(['title', 'editor'], $withoutFields['supports']);
self::assertTrue($withoutFields['divi']);
self::assertSame(5, $withoutFields['count']);
self::assertSame([['name' => 'category', 'label' => 'Kategorien', 'hierarchical' => true]], $withoutFields['taxonomies']);
self::assertArrayNotHasKey('fields', $withoutFields);
self::assertSame('footnotes', $withFields['fields'][0]['key']);
self::assertNull($schema->describe('et_pb_layout', false));
}
}
- Step 2: Run – muss fehlschlagen
Run: composer test -- --filter 'RegisteredMetaSourceTest|SampledMetaSourceTest|ContentSchemaTest'
Expected: FAIL, Klassen nicht gefunden.
- Step 3: Implementierung
src/Integrations/WordPress/Schema/FieldDefinition.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Schema;
/**
* Description of one field of a post type, independent of where it comes from.
*/
final class FieldDefinition
{
public const WRITABLE = 'writable';
public const READONLY = 'readonly';
public const UNKNOWN = 'unknown';
/**
* @param list<array{value: string, label: string}>|null $choices
* @param array{object_type: string, name: ?string}|null $related
*/
public function __construct(
public readonly string $key,
public readonly string $label,
public readonly string $type,
public readonly string $source,
public readonly string $status,
public readonly bool $required = false,
public readonly bool $multiple = false,
public readonly ?array $choices = null,
public readonly ?array $related = null,
public readonly string $description = ''
) {
}
/** @return array<string, mixed> */
public function toArray(): array
{
return [
'key' => $this->key,
'label' => $this->label,
'type' => $this->type,
'source' => $this->source,
'status' => $this->status,
'required' => $this->required,
'multiple' => $this->multiple,
'choices' => $this->choices,
'related' => $this->related,
'description' => $this->description,
];
}
}
src/Integrations/WordPress/Schema/FieldSource.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Schema;
use RuntimeException;
/**
* A provider of field definitions for post types that can also read and write
* the values of its own fields (Pods via the Pods API, everything else via meta).
*/
interface FieldSource
{
public function id(): string;
/** @return list<FieldDefinition> */
public function fieldsFor(string $postType): array;
public function read(int $postId, FieldDefinition $field): mixed;
/**
* @param array<string, mixed> $values Validated values; null clears the field.
* @param array<string, FieldDefinition> $definitions All definitions of the post type, by key.
* @throws RuntimeException When the value could not be stored.
*/
public function write(int $postId, string $postType, array $values, array $definitions): void;
}
src/Integrations/WordPress/Schema/MetaFieldSource.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Schema;
/**
* Shared read/write via the post meta API.
*/
abstract class MetaFieldSource implements FieldSource
{
public function read(int $postId, FieldDefinition $field): mixed
{
return get_post_meta($postId, $field->key, ! $field->multiple);
}
public function write(int $postId, string $postType, array $values, array $definitions): void
{
foreach ($values as $key => $value) {
$key = (string) $key;
if ($value === null) {
delete_post_meta($postId, $key);
continue;
}
$field = $definitions[$key] ?? null;
if ($field !== null && $field->multiple && is_array($value) && array_is_list($value)) {
delete_post_meta($postId, $key);
foreach ($value as $item) {
add_post_meta($postId, $key, wp_slash($item));
}
continue;
}
update_post_meta($postId, $key, wp_slash($value));
}
}
}
src/Integrations/WordPress/Schema/RegisteredMetaSource.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Schema;
/**
* Fields registered with register_meta()/register_post_meta().
*/
final class RegisteredMetaSource extends MetaFieldSource
{
private const TYPES = ['string', 'boolean', 'integer', 'number', 'array', 'object'];
public function id(): string
{
return 'meta';
}
public function fieldsFor(string $postType): array
{
$keys = array_merge(get_registered_meta_keys('post', ''), get_registered_meta_keys('post', $postType));
$fields = [];
foreach ($keys as $key => $args) {
$key = (string) $key;
$args = is_array($args) ? $args : [];
$type = (string) ($args['type'] ?? 'string');
$readonly = is_protected_meta($key, 'post') && empty($args['show_in_rest']);
$fields[] = new FieldDefinition(
$key,
$key,
in_array($type, self::TYPES, true) ? $type : 'unknown',
'meta',
$readonly ? FieldDefinition::READONLY : FieldDefinition::WRITABLE,
false,
! (bool) ($args['single'] ?? false),
null,
null,
(string) ($args['description'] ?? '')
);
}
return $fields;
}
}
src/Integrations/WordPress/Schema/SampledMetaSource.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Schema;
/**
* Meta keys actually used by the most recent posts of a type. Keys starting with
* "_" are internal and only shown when another source knows them.
*/
final class SampledMetaSource extends MetaFieldSource
{
private const SAMPLE_POSTS = 50;
private const MAX_KEYS = 200;
public function __construct(private ?\wpdb $db = null)
{
}
public function id(): string
{
return 'sample';
}
public function fieldsFor(string $postType): array
{
$wpdb = $this->db ?? $GLOBALS['wpdb'];
$keys = $wpdb->get_col($wpdb->prepare(
"SELECT DISTINCT pm.meta_key FROM {$wpdb->postmeta} pm
INNER JOIN (
SELECT ID FROM {$wpdb->posts}
WHERE post_type = %s AND post_status NOT IN ('auto-draft', 'trash')
ORDER BY ID DESC LIMIT %d
) recent ON recent.ID = pm.post_id
ORDER BY pm.meta_key LIMIT %d",
$postType,
self::SAMPLE_POSTS,
self::MAX_KEYS
));
$fields = [];
foreach ((array) $keys as $key) {
$key = (string) $key;
if ($key === '' || str_starts_with($key, '_')) {
continue;
}
$fields[] = new FieldDefinition($key, $key, 'unknown', 'sample', FieldDefinition::UNKNOWN);
}
return $fields;
}
}
src/Integrations/WordPress/Schema/ContentSchema.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Schema;
use LogicException;
/**
* Detects the content model of this installation (spec §6.1): post types with a
* UI, their taxonomies and their fields merged from all field sources.
* Not final so abilities can be unit-tested with a mock.
*/
class ContentSchema
{
/** Post types handled elsewhere (media tools, Divi tools, WordPress internals). */
public const EXCLUDED = [
'attachment', 'nav_menu_item', 'revision', 'wp_block', 'wp_template', 'wp_template_part',
'wp_navigation', 'wp_global_styles', 'wp_font_family', 'wp_font_face', 'et_pb_layout',
'et_theme_builder', 'et_template', 'et_header_layout', 'et_body_layout', 'et_footer_layout',
'et_pb_canvas', '_et_pb_speculation',
];
private const COUNTED_STATUSES = ['publish', 'draft', 'pending', 'private', 'future'];
/** @var callable(string): bool */
private $diviEnabled;
/** @var array<string, array<string, FieldDefinition>> */
private array $fieldCache = [];
/**
* @param list<FieldSource> $sources Highest priority first.
*/
public function __construct(private array $sources, ?callable $diviEnabled = null)
{
$this->diviEnabled = $diviEnabled ?? static function (string $postType): bool {
return function_exists('et_builder_enabled_for_post_type')
&& (bool) et_builder_enabled_for_post_type($postType);
};
}
/** @return list<string> */
public function postTypeNames(): array
{
$excluded = apply_filters('lit_mcp_excluded_post_types', self::EXCLUDED);
$excluded = is_array($excluded) ? $excluded : self::EXCLUDED;
$names = array_values(array_diff(
array_map('strval', array_keys(get_post_types(['show_ui' => true], 'names'))),
$excluded
));
sort($names);
return $names;
}
public function isManaged(string $postType): bool
{
return in_array($postType, $this->postTypeNames(), true);
}
/** @return list<array<string, mixed>> */
public function describePostTypes(): array
{
$types = [];
foreach ($this->postTypeNames() as $name) {
$description = $this->describe($name, false);
if ($description !== null) {
$types[] = $description;
}
}
return $types;
}
/** @return array<string, mixed>|null Null when the post type is not managed. */
public function describe(string $postType, bool $withFields): ?array
{
$object = $this->isManaged($postType) ? get_post_type_object($postType) : null;
if ($object === null) {
return null;
}
$description = [
'name' => $postType,
'label' => (string) $object->labels->name,
'singular_label' => (string) $object->labels->singular_name,
'hierarchical' => (bool) $object->hierarchical,
'supports' => array_map('strval', array_keys(get_all_post_type_supports($postType))),
'divi' => $this->diviEnabled($postType),
'count' => $this->count($postType),
'taxonomies' => $this->taxonomies($postType),
];
if ($withFields) {
$description['fields'] = array_values(array_map(
static fn (FieldDefinition $field): array => $field->toArray(),
$this->fields($postType)
));
}
return $description;
}
/** @return array<string, FieldDefinition> */
public function fields(string $postType): array
{
if (! isset($this->fieldCache[$postType])) {
$merged = [];
foreach ($this->sources as $source) {
foreach ($source->fieldsFor($postType) as $field) {
$merged[$field->key] ??= $field;
}
}
ksort($merged);
$this->fieldCache[$postType] = $merged;
}
return $this->fieldCache[$postType];
}
public function sourceOf(FieldDefinition $field): FieldSource
{
foreach ($this->sources as $source) {
if ($source->id() === $field->source) {
return $source;
}
}
throw new LogicException(sprintf('No field source "%s" registered.', $field->source));
}
/** @return list<array{name: string, label: string, hierarchical: bool}> */
public function taxonomies(string $postType): array
{
$result = [];
foreach (get_object_taxonomies($postType, 'objects') as $taxonomy) {
if (! $taxonomy->show_ui) {
continue;
}
$result[] = [
'name' => (string) $taxonomy->name,
'label' => (string) $taxonomy->labels->name,
'hierarchical' => (bool) $taxonomy->hierarchical,
];
}
return $result;
}
public function diviEnabled(string $postType): bool
{
return ($this->diviEnabled)($postType);
}
private function count(string $postType): int
{
$counts = (array) wp_count_posts($postType);
$total = 0;
foreach (self::COUNTED_STATUSES as $status) {
$total += (int) ($counts[$status] ?? 0);
}
return $total;
}
}
- Step 4: Run – muss bestehen
Run: composer test && composer lint
Expected: PASS. Falls phpcs in SampledMetaSource WordPress.DB.PreparedSQL meldet, obwohl nur $wpdb->-Eigenschaften interpoliert sind: die Zeile mit $wpdb->get_col( mit // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only core table names interpolated. versehen.
- Step 5: Commit
git add src/Integrations/WordPress/Schema tests/Unit/Integrations/WordPress/Schema
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: content schema with registered and sampled meta fields
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
Task 5: Pods-Feldquelle
Liest Felddefinitionen über pods_api()->load_pod() (Pods 3.x: Pods\Whatsit\Pod/Field) und schreibt Werte über pods($type, $id)->save() – damit Pods Beziehungen, Caches und Hooks selbst pflegt (Spec §6.2).
Files:
- Create:
src/Integrations/Pods/PodsFieldSource.php,tests/Integration/pods-fixture.php - Test:
tests/Unit/Integrations/Pods/PodsFieldSourceTest.php
Interfaces:
-
Consumes:
FieldSource,FieldDefinition(Task 4). -
Produces:
PodsFieldSource implements FieldSource(idpods):__construct(?callable $loadPod = null, ?callable $podFor = null)(Standard:pods_api()->load_pod(['name' => $type])bzw.pods($type, $id));static isAvailable(): bool. Typ-Abbildung:text|website|phone|email|password|slug|color|oembed→string,paragraph|wysiwyg|code→text,number|currency→number,boolean,date,datetime,time,file|avatar→file(relatedmedia),pickmitpick_object = custom-simple→choice(Auswahl auspick_custom, Zeilenwert|Label), sonstigespick→relation(relatedobject_type/nameausget_related_object_type()/get_related_object_name()); Layout-Felderheading,htmlwerden ausgelassen; unbekannte Typen →unknown/UNKNOWN. Integrationshelfertests/Integration/pods-fixture.php create|deletelegt den Pods-Post-Typlit_person(FelderroleText Pflicht,levelAuswahljunior|senior,mentorBeziehung auflit_person) an bzw. entfernt ihn. -
Step 1: Failing Test
tests/Unit/Integrations/Pods/PodsFieldSourceTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Pods;
use Brain\Monkey\Functions;
use Exception;
use LIT\MCP\Integrations\Pods\PodsFieldSource;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
final class PodsFieldSourceTest extends TestCase
{
/** @param array<string, mixed> $args */
private function field(string $name, string $type, array $args = [], array $extra = []): object
{
$field = Mockery::mock();
$field->allows('get_name')->andReturn($name);
$field->allows('get_label')->andReturn(ucfirst($name));
$field->allows('get_type')->andReturn($type);
$field->allows('is_required')->andReturn($extra['required'] ?? false);
$field->allows('is_multi_value')->andReturn($extra['multi'] ?? false);
$field->allows('get_related_object_type')->andReturn($extra['related_type'] ?? null);
$field->allows('get_related_object_name')->andReturn($extra['related_name'] ?? null);
$field->allows('get_arg')->andReturnUsing(static fn (string $key, $default = null) => $args[$key] ?? $default);
return $field;
}
private function source(?object $pod): PodsFieldSource
{
return new PodsFieldSource(static fn (string $name) => $pod ?? false);
}
/** @return array<string, array<string, mixed>> */
private function describe(): array
{
$pod = Mockery::mock();
$pod->allows('get_type')->andReturn('post_type');
$pod->allows('get_fields')->andReturn([
'role' => $this->field('role', 'text', ['description' => 'Funktion im Team'], ['required' => true]),
'level' => $this->field('level', 'pick', [
'pick_object' => 'custom-simple',
'pick_custom' => "junior|Junior\nsenior|Senior\nplain",
]),
'mentor' => $this->field('mentor', 'pick', ['pick_object' => 'post_type'], [
'related_type' => 'post_type',
'related_name' => 'lit_person',
]),
'photos' => $this->field('photos', 'file', [], ['multi' => true]),
'intro' => $this->field('intro', 'heading'),
'mystery' => $this->field('mystery', 'fancy-addon-type'),
]);
$fields = $this->source($pod)->fieldsFor('lit_person');
return array_column(array_map(static fn (FieldDefinition $f): array => $f->toArray(), $fields), null, 'key');
}
public function testMapsPodsFieldTypes(): void
{
$fields = $this->describe();
self::assertSame(['role', 'level', 'mentor', 'photos', 'mystery'], array_keys($fields));
self::assertSame('string', $fields['role']['type']);
self::assertTrue($fields['role']['required']);
self::assertSame('Funktion im Team', $fields['role']['description']);
self::assertSame('pods', $fields['role']['source']);
self::assertSame(FieldDefinition::WRITABLE, $fields['role']['status']);
self::assertSame('relation', $fields['mentor']['type']);
self::assertSame(['object_type' => 'post_type', 'name' => 'lit_person'], $fields['mentor']['related']);
self::assertSame('file', $fields['photos']['type']);
self::assertTrue($fields['photos']['multiple']);
self::assertSame(['object_type' => 'media', 'name' => null], $fields['photos']['related']);
self::assertSame('unknown', $fields['mystery']['type']);
self::assertSame(FieldDefinition::UNKNOWN, $fields['mystery']['status']);
}
public function testParsesCustomSimpleChoices(): void
{
self::assertSame(
[
['value' => 'junior', 'label' => 'Junior'],
['value' => 'senior', 'label' => 'Senior'],
['value' => 'plain', 'label' => 'plain'],
],
$this->describe()['level']['choices']
);
}
public function testIgnoresNonPodsAndNonPostTypePods(): void
{
$taxonomyPod = Mockery::mock();
$taxonomyPod->allows('get_type')->andReturn('taxonomy');
self::assertSame([], $this->source(null)->fieldsFor('page'));
self::assertSame([], $this->source($taxonomyPod)->fieldsFor('genre'));
}
public function testReadsRelationsAsIdsAndOtherFieldsRaw(): void
{
Functions\when('get_post_type')->justReturn('lit_person');
$pod = Mockery::mock();
$pod->expects('field')->with(['name' => 'mentor', 'output' => 'ids', 'single' => true])->andReturn('12');
$pod->expects('field')->with(['name' => 'photos', 'output' => 'ids', 'single' => false])->andReturn(['3', '4']);
$pod->expects('field')->with(['name' => 'role', 'single' => true, 'raw' => true])->andReturn('Leitung');
$source = new PodsFieldSource(null, static fn (string $type, int $id) => $pod);
self::assertSame(12, $source->read(5, new FieldDefinition('mentor', 'Mentor', 'relation', 'pods', 'writable')));
self::assertSame(
[3, 4],
$source->read(5, new FieldDefinition('photos', 'Fotos', 'file', 'pods', 'writable', false, true))
);
self::assertSame('Leitung', $source->read(5, new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable')));
}
public function testWriteSavesThroughPodsAndMapsNullToEmpty(): void
{
$definitions = [
'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable'),
'photos' => new FieldDefinition('photos', 'Fotos', 'file', 'pods', 'writable', false, true),
];
$pod = Mockery::mock();
$pod->expects('save')
->with(['role' => '', 'photos' => []], null, null, ['error_mode' => 'exception'])
->andReturn(5);
(new PodsFieldSource(null, static fn () => $pod))
->write(5, 'lit_person', ['role' => null, 'photos' => null], $definitions);
}
public function testWriteFailureBecomesRuntimeException(): void
{
$pod = Mockery::mock();
$pod->allows('save')->andThrow(new Exception('Pods error'));
$this->expectException(RuntimeException::class);
(new PodsFieldSource(null, static fn () => $pod))->write(5, 'lit_person', ['role' => 'x'], []);
}
}
- Step 2: Run – muss fehlschlagen
Run: composer test -- --filter PodsFieldSourceTest
Expected: FAIL, Klasse nicht gefunden.
- Step 3: Implementierung
src/Integrations/Pods/PodsFieldSource.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Pods;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
use LIT\MCP\Integrations\WordPress\Schema\FieldSource;
use RuntimeException;
use Throwable;
/**
* Pods field adapter (spec §6.1/§6.2): definitions from pods_api()->load_pod(),
* values read and written through the Pods API so relationships and caches stay
* consistent. Works with Pods 3.x (Pods\Whatsit\Pod / Pods\Whatsit\Field).
*/
final class PodsFieldSource implements FieldSource
{
private const TYPES = [
'text' => 'string', 'website' => 'string', 'phone' => 'string', 'email' => 'string',
'password' => 'string', 'slug' => 'string', 'color' => 'string', 'oembed' => 'string',
'paragraph' => 'text', 'wysiwyg' => 'text', 'code' => 'text',
'number' => 'number', 'currency' => 'number',
'boolean' => 'boolean',
'date' => 'date', 'datetime' => 'datetime', 'time' => 'time',
'file' => 'file', 'avatar' => 'file',
];
private const LAYOUT_TYPES = ['heading', 'html'];
private const ID_TYPES = ['relation', 'file'];
/** @var callable(string): mixed */
private $loadPod;
/** @var callable(string, int): mixed */
private $podFor;
public function __construct(?callable $loadPod = null, ?callable $podFor = null)
{
$this->loadPod = $loadPod ?? static fn (string $name): mixed => pods_api()->load_pod(['name' => $name]);
$this->podFor = $podFor ?? static fn (string $name, int $id): mixed => pods($name, $id);
}
public static function isAvailable(): bool
{
return function_exists('pods_api') && function_exists('pods');
}
public function id(): string
{
return 'pods';
}
public function fieldsFor(string $postType): array
{
$pod = ($this->loadPod)($postType);
if (! is_object($pod) || $pod->get_type() !== 'post_type') {
return [];
}
$fields = [];
foreach ($pod->get_fields() as $field) {
$type = (string) $field->get_type();
if (in_array($type, self::LAYOUT_TYPES, true)) {
continue;
}
$fields[] = $this->definition($field, $type);
}
return $fields;
}
public function read(int $postId, FieldDefinition $field): mixed
{
$pod = ($this->podFor)((string) get_post_type($postId), $postId);
if (! is_object($pod)) {
return null;
}
if (in_array($field->type, self::ID_TYPES, true)) {
$ids = $pod->field(['name' => $field->key, 'output' => 'ids', 'single' => ! $field->multiple]);
if ($field->multiple) {
return array_values(array_map('intval', array_filter((array) $ids, 'is_numeric')));
}
return is_numeric($ids) ? (int) $ids : null;
}
return $pod->field(['name' => $field->key, 'single' => true, 'raw' => true]);
}
public function write(int $postId, string $postType, array $values, array $definitions): void
{
$data = [];
foreach ($values as $key => $value) {
$multiple = isset($definitions[$key]) && $definitions[$key]->multiple;
$data[$key] = $value ?? ($multiple ? [] : '');
}
$pod = ($this->podFor)($postType, $postId);
if (! is_object($pod)) {
throw new RuntimeException(sprintf('Pods object for %s #%d is not available.', $postType, $postId));
}
try {
$result = $pod->save($data, null, null, ['error_mode' => 'exception']);
} catch (Throwable $e) {
throw new RuntimeException('Pods could not save the fields: ' . $e->getMessage(), 0, $e);
}
if ($result === false || is_wp_error($result)) {
throw new RuntimeException('Pods could not save the fields.');
}
}
private function definition(object $field, string $type): FieldDefinition
{
$normalized = self::TYPES[$type] ?? 'unknown';
$choices = null;
$related = null;
if ($type === 'pick') {
if ((string) $field->get_arg('pick_object', '') === 'custom-simple') {
$normalized = 'choice';
$choices = $this->choices((string) $field->get_arg('pick_custom', ''));
} else {
$normalized = 'relation';
$name = $field->get_related_object_name();
$related = [
'object_type' => (string) $field->get_related_object_type(),
'name' => $name === null ? null : (string) $name,
];
}
} elseif ($normalized === 'file') {
$related = ['object_type' => 'media', 'name' => null];
}
return new FieldDefinition(
(string) $field->get_name(),
(string) $field->get_label(),
$normalized,
'pods',
$normalized === 'unknown' ? FieldDefinition::UNKNOWN : FieldDefinition::WRITABLE,
(bool) $field->is_required(),
(bool) $field->is_multi_value(),
$choices,
$related,
(string) $field->get_arg('description', '')
);
}
/** @return list<array{value: string, label: string}> */
private function choices(string $raw): array
{
$choices = [];
foreach (preg_split('/\r\n|\r|\n/', trim($raw)) ?: [] as $line) {
$line = trim(trim($line), '|');
if ($line === '') {
continue;
}
$parts = explode('|', $line, 2);
$value = trim($parts[0]);
$choices[] = ['value' => $value, 'label' => trim($parts[1] ?? $value)];
}
return $choices;
}
}
tests/Integration/pods-fixture.php:
<?php
/**
* Creates or removes the Pods test content type "lit_person" for integration tests.
*
* Usage (inside DDEV, Pods must be active):
* wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/pods-fixture.php create
* wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/pods-fixture.php delete
*
* No strict_types: wp eval-file runs this through eval().
*/
defined('WP_CLI') && WP_CLI || exit;
if (! function_exists('pods_api')) {
fwrite(STDERR, "Pods is not active\n");
exit(1);
}
$action = $args[0] ?? 'create';
$api = pods_api();
if ($action === 'delete') {
if ($api->load_pod(['name' => 'lit_person'])) {
$api->delete_pod(['name' => 'lit_person'], false, true);
}
echo "deleted\n";
return;
}
if (! $api->load_pod(['name' => 'lit_person'])) {
$api->save_pod([
'name' => 'lit_person',
'label' => 'Personen',
'label_singular' => 'Person',
'type' => 'post_type',
'storage' => 'meta',
'public' => 1,
'show_ui' => 1,
]);
}
$fields = [
['name' => 'role', 'label' => 'Funktion', 'type' => 'text', 'required' => 1],
[
'name' => 'level',
'label' => 'Stufe',
'type' => 'pick',
'pick_object' => 'custom-simple',
'pick_custom' => "junior|Junior\nsenior|Senior",
],
[
'name' => 'mentor',
'label' => 'Mentor',
'type' => 'pick',
'pick_object' => 'post_type',
'pick_val' => 'lit_person',
'pick_format_type' => 'single',
],
];
foreach ($fields as $field) {
$api->save_field(array_merge(['pod' => 'lit_person'], $field));
}
echo "created\n";
- Step 4: Run – muss bestehen
Run: composer test && composer lint
Expected: PASS.
- Step 5: Integration mit echtem Pods
Run:
cd /home/janniclas/Projekte/WordPress
timeout 120 ddev wp plugin activate pods
timeout 120 ddev wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/pods-fixture.php create
timeout 120 ddev wp eval '
$s = new \LIT\MCP\Integrations\WordPress\Schema\ContentSchema([
new \LIT\MCP\Integrations\Pods\PodsFieldSource(),
new \LIT\MCP\Integrations\WordPress\Schema\RegisteredMetaSource(),
new \LIT\MCP\Integrations\WordPress\Schema\SampledMetaSource(),
]);
echo wp_json_encode(array_map(static fn ($f) => $f->toArray(), $s->fields("lit_person")), JSON_PRETTY_PRINT);'
timeout 120 ddev wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/pods-fixture.php delete
timeout 120 ddev wp plugin deactivate pods
Expected: JSON mit role (type string, required true), level (type choice, zwei choices), mentor (type relation, related.name lit_person), alle mit source pods. Weicht die Typ- oder Beziehungsabbildung ab, definition() an die tatsächliche Pods-Ausgabe anpassen und einen Unit-Test für den Fall ergänzen, bevor weitergemacht wird.
- Step 6: Commit
git add src/Integrations/Pods tests/Unit/Integrations/Pods tests/Integration/pods-fixture.php
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Pods field source for schema detection and writes
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
Task 6: lit/content-types, WordPressIntegration, Boot-Verdrahtung, Server-Instructions
Files:
- Create:
src/Integrations/WordPress/WordPressIntegration.php,src/Integrations/WordPress/Content/ContentTypesAbility.php - Modify:
src/Plugin.php(komplett ersetzen),src/Guides/InstructionsBuilder.php(komplett ersetzen) - Test:
tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php,tests/Unit/Integrations/WordPress/Content/ContentTypesAbilityTest.php,tests/Unit/Guides/InstructionsBuilderTest.php(Test ergänzen)
Interfaces:
-
Consumes:
ContentSchema, Feldquellen (Tasks 4–5),ChangeLog,ChangeLogInstaller,ChangeLogCleanup,Limits(Task 3),Ability,Errors,Integration,IntegrationRegistry(Etappe 0). -
Produces:
WordPressIntegration implements Integration:__construct(ContentSchema $schema, ChangeLog $log),id()=wordpress, immer verfügbar;abilities()wächst in den Tasks 8–15 (jeder Task gibt die vollständige neue Methode an).ContentTypesAbility:__construct(ContentSchema $schema); Namelit/content-types; Input{post_type?: string}; Output{post_types: list<object>}; direkt; readonly, idempotent.InstructionsBuilder::__construct(IntegrationRegistry $registry, ?callable $postTypeNames = null)– mit Callable zusätzliche ZeileInhaltstypen: … (Felder und Taxonomien: lit-content-types).Plugin::boot()registriert dieWordPressIntegration;Plugin::contentSchema(): ContentSchema(privat) baut die Quellen: Pods (fallsPodsFieldSource::isAvailable()), registrierte Meta, Stichprobe.- Hook-Vertrag für spätere Etappen: Action
lit_mcp_after_savemit(int $postId, string $ability, array $input)– wird ab Task 9 nach jedem Schreiben eines Eintrags ausgelöst (Spec §6.2).
-
Step 1: Failing Tests
tests/Unit/Integrations/WordPress/Content/ContentTypesAbilityTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Content;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\WordPress\Content\ContentTypesAbility;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Error;
final class ContentTypesAbilityTest extends TestCase
{
public function testListsAllTypesWithoutFields(): void
{
$schema = Mockery::mock(ContentSchema::class);
$schema->expects('describePostTypes')->andReturn([['name' => 'page'], ['name' => 'post']]);
$result = (new ContentTypesAbility($schema))->execute([]);
self::assertSame(['post_types' => [['name' => 'page'], ['name' => 'post']]], $result);
}
public function testSingleTypeIncludesFields(): void
{
$schema = Mockery::mock(ContentSchema::class);
$schema->expects('describe')->with('lit_person', true)->andReturn(['name' => 'lit_person', 'fields' => []]);
$result = (new ContentTypesAbility($schema))->execute(['post_type' => 'lit_person']);
self::assertSame([['name' => 'lit_person', 'fields' => []]], $result['post_types']);
}
public function testUnknownTypeIsNotFoundWithTheAvailableTypes(): void
{
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('describe')->andReturn(null);
$schema->allows('postTypeNames')->andReturn(['page', 'post']);
$result = (new ContentTypesAbility($schema))->execute(['post_type' => 'nope']);
self::assertInstanceOf(WP_Error::class, $result);
self::assertSame('lit_not_found', $result->get_error_code());
self::assertStringContainsString('page, post', $result->get_error_message());
}
public function testPermissionRequiresEditPosts(): void
{
Functions\when('current_user_can')->justReturn(false);
$ability = new ContentTypesAbility(Mockery::mock(ContentSchema::class));
self::assertSame('lit_forbidden', $ability->permission([])->get_error_code());
}
public function testMetadata(): void
{
$ability = new ContentTypesAbility(Mockery::mock(ContentSchema::class));
self::assertSame('lit/content-types', $ability->name());
self::assertTrue($ability->isDirect());
self::assertSame(['readonly' => true, 'destructive' => false, 'idempotent' => true], $ability->annotations());
self::assertFalse($ability->inputSchema()['additionalProperties']);
}
}
tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\WordPressIntegration;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class WordPressIntegrationTest extends TestCase
{
/** Grows with every task that adds abilities – keep in the order of abilities(). */
private const EXPECTED = [
'lit/content-types',
];
public function testProvidesTheWordPressAbilitiesInOrder(): void
{
$integration = new WordPressIntegration(Mockery::mock(ContentSchema::class), Mockery::mock(ChangeLog::class));
self::assertSame('wordpress', $integration->id());
self::assertTrue($integration->isAvailable());
self::assertSame(
self::EXPECTED,
array_map(static fn (Ability $ability): string => $ability->name(), $integration->abilities())
);
}
public function testEveryAbilityHasAnObjectInputSchemaThatRejectsUnknownKeys(): void
{
$integration = new WordPressIntegration(Mockery::mock(ContentSchema::class), Mockery::mock(ChangeLog::class));
foreach ($integration->abilities() as $ability) {
$schema = $ability->inputSchema();
self::assertSame('object', $schema['type'], $ability->name());
self::assertFalse($schema['additionalProperties'], $ability->name());
self::assertMatchesRegularExpression('#^lit/[a-z]+(-[a-z]+)+$#', $ability->name());
}
}
}
In tests/Unit/Guides/InstructionsBuilderTest.php diese Methode ergänzen:
public function testListsPostTypesWhenAProviderIsGiven(): void
{
$core = Mockery::mock(Integration::class);
$core->allows('id')->andReturn('core');
$core->allows('isAvailable')->andReturn(true);
$text = (new InstructionsBuilder(new IntegrationRegistry($core), static fn (): array => ['page', 'post']))->build();
self::assertStringContainsString('Inhaltstypen: page, post', $text);
self::assertStringContainsString('lit-content-types', $text);
self::assertStringContainsString('confirm: true', $text);
}
- Step 2: Run – muss fehlschlagen
Run: composer test -- --filter 'ContentTypesAbilityTest|WordPressIntegrationTest|InstructionsBuilderTest'
Expected: FAIL.
- Step 3: Implementierung
src/Integrations/WordPress/Content/ContentTypesAbility.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Content;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
final class ContentTypesAbility implements Ability
{
public function __construct(private ContentSchema $schema)
{
}
public function name(): string
{
return 'lit/content-types';
}
public function label(): string
{
return 'Inhaltstypen';
}
public function description(): string
{
return 'Listet die Inhaltstypen (Post-Typen) dieser Website mit Taxonomien, Anzahl und Divi-Fähigkeit. '
. 'Mit post_type zusätzlich alle Felder (Pods, WordPress-Meta) mit Typ, Pflicht, Auswahlwerten, '
. 'Beziehungsziel und Schreibstatus (writable/readonly/unknown).';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'post_type' => [
'type' => 'string',
'description' => 'Optional: nur diesen Inhaltstyp, dafür mit allen Feldern.',
],
],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return [
'type' => 'object',
'properties' => [
'post_types' => ['type' => 'array', 'items' => ['type' => 'object']],
],
'required' => ['post_types'],
];
}
public function annotations(): array
{
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return true;
}
public function permission(array $input): bool|WP_Error
{
return current_user_can('edit_posts')
? true
: Errors::forbidden('Keine Berechtigung, Inhalte zu bearbeiten.');
}
public function execute(array $input): mixed
{
$postType = isset($input['post_type']) && is_string($input['post_type']) ? $input['post_type'] : '';
if ($postType === '') {
return ['post_types' => $this->schema->describePostTypes()];
}
$description = $this->schema->describe($postType, true);
if ($description === null) {
return Errors::notFound(
sprintf('Inhaltstyp "%s" ist nicht verfügbar.', $postType),
'Verfügbare Inhaltstypen: ' . implode(', ', $this->schema->postTypeNames()) . '.'
);
}
return ['post_types' => [$description]];
}
}
src/Integrations/WordPress/WordPressIntegration.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\WordPress\Content\ContentTypesAbility;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
/**
* WordPress basics (spec §6): content, status, copies, revisions, menus, settings.
* Always available.
*/
final class WordPressIntegration implements Integration
{
public function __construct(private ContentSchema $schema, private ChangeLog $log)
{
}
public function id(): string
{
return 'wordpress';
}
public function isAvailable(): bool
{
return true;
}
public function abilities(): array
{
return [
new ContentTypesAbility($this->schema),
];
}
}
src/Guides/InstructionsBuilder.php (komplett ersetzen):
<?php
declare(strict_types=1);
namespace LIT\MCP\Guides;
use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\IntegrationRegistry;
/**
* Builds the short server instructions every MCP client loads on connect.
* Keep this text short: it occupies context in every conversation.
*/
final class InstructionsBuilder
{
/** @var (callable(): list<string>)|null */
private $postTypeNames;
public function __construct(private IntegrationRegistry $registry, ?callable $postTypeNames = null)
{
$this->postTypeNames = $postTypeNames;
}
public function build(): string
{
$active = implode(', ', array_map(
static fn (Integration $integration): string => $integration->id(),
$this->registry->available()
));
$lines = [
'LIT MCP pflegt diese WordPress-Website (Inhalte, Medien, Divi 5, Pods).',
'Aktive Integrationen: ' . $active . '.',
];
if ($this->postTypeNames !== null) {
$lines[] = 'Inhaltstypen: ' . implode(', ', ($this->postTypeNames)())
. ' (Felder und Taxonomien: lit-content-types).';
}
return implode("\n", array_merge($lines, [
'Regeln:',
'- Vor dem ersten Arbeitsschritt lit-guide (ohne Topic) aufrufen; Details je Thema über lit-guide '
. 'mit topic.',
'- Änderungen erzeugen native Divi-Module; Variablen und Presets vor festen Werten; Code-Modul nur, '
. 'wenn kein natives Modul passt.',
'- Den Status (Entwurf/veröffentlicht) nie ändern, außer der Nutzer verlangt es ausdrücklich; '
. 'neue Inhalte entstehen als Entwurf.',
'- Beim Bearbeiten den rev-Wert aus dem letzten Lesevorgang mitgeben (bei Divi-Tools Pflicht).',
'- Löschen und Ersetzen erst nach Rückfrage beim Nutzer (confirm: true).',
'- Fehler enthalten einen hint – ihm folgen, statt blind zu wiederholen.',
]));
}
}
src/Plugin.php (komplett ersetzen):
<?php
declare(strict_types=1);
namespace LIT\MCP;
use LIT\MCP\Abilities\AbilityRegistrar;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\ChangeLog\ChangeLogCleanup;
use LIT\MCP\ChangeLog\ChangeLogInstaller;
use LIT\MCP\Guides\InstructionsBuilder;
use LIT\MCP\Integrations\Core\CoreIntegration;
use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Integrations\Pods\PodsFieldSource;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Schema\RegisteredMetaSource;
use LIT\MCP\Integrations\WordPress\Schema\SampledMetaSource;
use LIT\MCP\Integrations\WordPress\WordPressIntegration;
use LIT\MCP\Server\RequestContext;
use LIT\MCP\Server\ServerRegistrar;
/**
* Wires WP-LIT-MCP into WordPress.
*/
final class Plugin
{
public const VERSION = '0.1.0';
private static string $pluginFile = '';
public static function boot(string $pluginFile): void
{
self::$pluginFile = $pluginFile;
$missing = (new Dependencies())->missing();
if ($missing !== []) {
self::noticeMissing($missing);
return;
}
(new Access())->register();
(new RequestContext())->register();
$changeLog = new ChangeLog();
(new ChangeLogInstaller($changeLog))->maybeInstall();
(new ChangeLogCleanup($changeLog, new Limits()))->register();
$schema = self::contentSchema();
$registry = new IntegrationRegistry(
new CoreIntegration(self::pluginDir() . '/docs/guides'),
new WordPressIntegration($schema, $changeLog)
);
(new AbilityRegistrar($registry))->register();
(new ServerRegistrar(
$registry,
new InstructionsBuilder($registry, static fn (): array => $schema->postTypeNames())
))->register();
\WPMedia\MCP\OAuth\Bootstrap::instance();
}
public static function pluginDir(): string
{
return dirname(self::$pluginFile);
}
/**
* @param list<string> $missing
*/
public static function noticeMissing(array $missing): void
{
add_action('admin_notices', static function () use ($missing): void {
printf(
'<div class="notice notice-error"><p>%s</p></div>',
esc_html(sprintf(
/* translators: %s: comma separated list of missing components */
__('WP-LIT-MCP ist inaktiv. Es fehlt: %s', 'wp-lit-mcp'),
implode(', ', $missing)
))
);
});
}
private static function contentSchema(): ContentSchema
{
$sources = [new RegisteredMetaSource(), new SampledMetaSource()];
if (PodsFieldSource::isAvailable()) {
array_unshift($sources, new PodsFieldSource());
}
return new ContentSchema($sources);
}
}
- Step 4: Run – muss bestehen
Run: composer test && composer lint
Expected: PASS.
- Step 5: Integration – Tool sichtbar, Regression Etappe 0
Run:
cd /home/janniclas/Projekte/WordPress
timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/mcp-smoke.sh
timeout 120 ddev wp eval 'do_action("rest_api_init"); var_dump(wp_get_ability("lit/content-types") !== null);'
Expected: ALL CHECKS PASSED (Instructions enthalten weiterhin „LIT MCP pflegt“) und bool(true).
- Step 6: Commit
git add src/Plugin.php src/Guides/InstructionsBuilder.php src/Integrations/WordPress/WordPressIntegration.php src/Integrations/WordPress/Content/ContentTypesAbility.php tests/Unit/Integrations/WordPress tests/Unit/Guides/InstructionsBuilderTest.php
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: content-types tool and WordPress integration
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
Task 7: Validierung und Schreibhilfen (FieldValidator, TermAssigner, PostGuard, RevisionPoint)
Files:
- Create:
src/Integrations/WordPress/Schema/FieldValidator.php,src/Integrations/WordPress/Schema/TermAssigner.php,src/Integrations/WordPress/PostGuard.php,src/Integrations/WordPress/RevisionPoint.php,tests/Stubs/WpPost.php,tests/Stubs/WpTerm.php - Modify:
tests/bootstrap.php - Test:
tests/Unit/Integrations/WordPress/Schema/FieldValidatorTest.php,tests/Unit/Integrations/WordPress/Schema/TermAssignerTest.php,tests/Unit/Integrations/WordPress/PostGuardTest.php,tests/Unit/Integrations/WordPress/RevisionPointTest.php
Interfaces:
-
Consumes:
FieldDefinition,ContentSchema(Task 4),Errors(Task 1). -
Produces:
FieldValidator(final):validate(array<string, FieldDefinition> $definitions, array<string, mixed> $values, bool $isCreate): array{values: array<string, mixed>, errors: array<string, string>, warnings: list<string>}– unbekannte undreadonly-Felder sind Fehler;unknown-Felder erzeugen eine Warnung;nullleert ein Feld; Pflichtfelder müssen beim Anlegen gesetzt sein und dürfen beim Ändern nicht geleert werden.TermAssigner(final):resolve(string $postType, array $terms): array{terms: array<string, list<int|string>>, errors: array<string, string>}(Eingabe{taxonomie: [ID | Name, …]}; Ints = vorhandene IDs, Strings = vorhandener Name/Slug → ID, sonst neuer Name, fallsedit_termserlaubt);assign(int $postId, array $resolved): void(legt neue Begriffe an und ersetzt die Zuordnung je Taxonomie; wirftRuntimeException).PostGuard(final, statisch):editable(mixed $postId, ContentSchema $schema, string $field = 'post_id'): WP_Post|WP_Error;postType(mixed $postType, ContentSchema $schema, string $cap): object|WP_Error($capist ein Schlüssel aus$postTypeObject->cap, z. B.edit_posts,create_posts);id(mixed $value): ?int.RevisionPoint(final, statisch):capture(int $postId): ?int(speichert den aktuellen Stand als Revision, falls geändert, und gibt die neueste Revisions-ID zurück);latest(int $postId): ?int.- Test-Stubs
WP_Post(Konstruktorarray $fields) undWP_Term(Konstruktorarray $fields).
-
Step 1: Stubs
tests/Stubs/WpPost.php:
<?php
declare(strict_types=1);
if (! class_exists('WP_Post')) {
/**
* Minimal WP_Post: untyped public properties like the real class.
*/
class WP_Post
{
public $ID = 0;
public $post_type = 'post';
public $post_status = 'draft';
public $post_title = '';
public $post_content = '';
public $post_excerpt = '';
public $post_name = '';
public $post_parent = 0;
public $menu_order = 0;
public $post_author = '1';
public $post_date = '2026-09-25 10:00:00';
public $post_date_gmt = '2026-09-25 08:00:00';
public $post_modified = '2026-09-25 10:00:00';
public $post_mime_type = '';
public $comment_status = 'closed';
public $ping_status = 'closed';
public $post_password = '';
/** @param array<string, mixed> $fields */
public function __construct(array $fields = [])
{
foreach ($fields as $key => $value) {
$this->{$key} = $value;
}
}
}
}
tests/Stubs/WpTerm.php:
<?php
declare(strict_types=1);
if (! class_exists('WP_Term')) {
class WP_Term
{
public $term_id = 0;
public $name = '';
public $slug = '';
public $taxonomy = '';
/** @param array<string, mixed> $fields */
public function __construct(array $fields = [])
{
foreach ($fields as $key => $value) {
$this->{$key} = $value;
}
}
}
}
tests/bootstrap.php: nach der Zeile mit Stubs/Wpdb.php ergänzen:
require_once __DIR__ . '/Stubs/WpPost.php';
require_once __DIR__ . '/Stubs/WpTerm.php';
- Step 2: Failing Tests
tests/Unit/Integrations/WordPress/Schema/FieldValidatorTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Schema;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
use LIT\MCP\Integrations\WordPress\Schema\FieldValidator;
use LIT\MCP\Tests\Unit\TestCase;
final class FieldValidatorTest extends TestCase
{
/** @return array<string, FieldDefinition> */
private function definitions(): array
{
return [
'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable', true),
'level' => new FieldDefinition('level', 'Stufe', 'choice', 'pods', 'writable', false, false, [
['value' => 'junior', 'label' => 'Junior'],
['value' => 'senior', 'label' => 'Senior'],
]),
'mentor' => new FieldDefinition('mentor', 'Mentor', 'relation', 'pods', 'writable', false, false, null, [
'object_type' => 'post_type',
'name' => 'lit_person',
]),
'photos' => new FieldDefinition('photos', 'Fotos', 'file', 'pods', 'writable', false, true, null, [
'object_type' => 'media',
'name' => null,
]),
'start' => new FieldDefinition('start', 'Beginn', 'date', 'pods', 'writable'),
'secret' => new FieldDefinition('_secret', '_secret', 'string', 'meta', 'readonly'),
'city' => new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown'),
];
}
public function testValidValuesPassUnchanged(): void
{
Functions\when('get_post_type')->alias(static fn (int $id) => [12 => 'lit_person', 3 => 'attachment'][$id] ?? false);
$values = ['role' => 'Leitung', 'level' => 'senior', 'mentor' => 12, 'photos' => [3], 'start' => '2026-10-01'];
$result = (new FieldValidator())->validate($this->definitions(), $values, true);
self::assertSame([], $result['errors']);
self::assertSame($values, $result['values']);
}
public function testRequiredFieldMustBeSetOnCreateAndNotClearedOnUpdate(): void
{
$validator = new FieldValidator();
self::assertSame('Pflichtfeld fehlt.', $validator->validate($this->definitions(), [], true)['errors']['role']);
self::assertSame(
'Pflichtfeld darf nicht geleert werden.',
$validator->validate($this->definitions(), ['role' => ''], false)['errors']['role']
);
self::assertSame([], $validator->validate($this->definitions(), ['level' => 'junior'], false)['errors']);
}
public function testUnknownReadonlyAndInvalidValuesAreReported(): void
{
Functions\when('get_post_type')->alias(static fn (int $id) => [7 => 'page', 3 => 'attachment'][$id] ?? false);
$errors = (new FieldValidator())->validate($this->definitions(), [
'role' => 'x',
'nope' => 1,
'secret' => 'x',
'level' => 'expert',
'mentor' => 7,
'photos' => 3,
'start' => '01.10.2026',
], false)['errors'];
self::assertStringContainsString('lit-content-types', $errors['nope']);
self::assertSame('Feld ist schreibgeschützt.', $errors['secret']);
self::assertSame('Erlaubte Werte: junior, senior.', $errors['level']);
self::assertSame('Eintrag 7 ist vom Typ page, erwartet lit_person.', $errors['mentor']);
self::assertSame('Liste erwartet.', $errors['photos']);
self::assertSame('Datum im Format JJJJ-MM-TT erwartet.', $errors['start']);
self::assertArrayNotHasKey('role', $errors);
}
public function testMissingRelatedPostAndNonMediaAreRejected(): void
{
Functions\when('get_post_type')->alias(static fn (int $id) => [5 => 'page'][$id] ?? false);
$errors = (new FieldValidator())->validate($this->definitions(), ['mentor' => 99, 'photos' => [5]], false)['errors'];
self::assertSame('Eintrag 99 existiert nicht.', $errors['mentor']);
self::assertSame('5 ist kein Medium der Mediathek.', $errors['photos']);
}
public function testUnknownTypedFieldsAreAcceptedWithAWarningAndNullClears(): void
{
$result = (new FieldValidator())->validate($this->definitions(), ['city' => ['any' => 'thing'], 'level' => null], false);
self::assertSame([], $result['errors']);
self::assertSame(['city' => ['any' => 'thing'], 'level' => null], $result['values']);
self::assertCount(1, $result['warnings']);
self::assertStringContainsString('city', $result['warnings'][0]);
}
}
tests/Unit/Integrations/WordPress/Schema/TermAssignerTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Schema;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\WordPress\Schema\TermAssigner;
use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException;
use WP_Error;
use WP_Term;
final class TermAssignerTest extends TestCase
{
private function stubTaxonomies(bool $mayEditTerms = true, bool $mayAssign = true): void
{
Functions\when('get_object_taxonomies')->justReturn(['category', 'post_tag']);
Functions\when('get_taxonomy')->justReturn((object) [
'cap' => (object) ['assign_terms' => 'assign_categories', 'edit_terms' => 'manage_categories'],
]);
Functions\when('current_user_can')->alias(static fn (string $cap): bool => match ($cap) {
'assign_categories' => $mayAssign,
'manage_categories' => $mayEditTerms,
default => false,
});
Functions\when('get_term')->alias(static fn (int $id) => $id === 4 ? new WP_Term(['term_id' => 4]) : null);
Functions\when('term_exists')->alias(
static fn (string $name) => $name === 'News' ? ['term_id' => '9', 'term_taxonomy_id' => '9'] : null
);
}
public function testResolvesIdsExistingNamesAndNewNames(): void
{
$this->stubTaxonomies();
$result = (new TermAssigner())->resolve('post', ['category' => [4, 'News', 'Neu']]);
self::assertSame([], $result['errors']);
self::assertSame(['category' => [4, 9, 'Neu']], $result['terms']);
}
public function testReportsForeignTaxonomyMissingIdsAndMissingRights(): void
{
$this->stubTaxonomies(false);
$result = (new TermAssigner())->resolve('post', [
'genre' => [1],
'category' => [77],
'post_tag' => ['Unbekannt'],
]);
self::assertSame('Taxonomie gehört nicht zu diesem Inhaltstyp.', $result['errors']['genre']);
self::assertSame('Begriff 77 existiert nicht.', $result['errors']['category']);
self::assertStringContainsString('keine Berechtigung', $result['errors']['post_tag']);
}
public function testAssignRightsAreRequired(): void
{
$this->stubTaxonomies(true, false);
$result = (new TermAssigner())->resolve('post', ['category' => [4]]);
self::assertSame('Keine Berechtigung, Begriffe dieser Taxonomie zuzuweisen.', $result['errors']['category']);
}
public function testAssignCreatesNewTermsAndReplacesTheAssignment(): void
{
Functions\expect('wp_insert_term')->once()->with('Neu', 'category')->andReturn(['term_id' => 15]);
Functions\expect('wp_set_object_terms')->once()->with(5, [4, 15], 'category', false)->andReturn([4, 15]);
(new TermAssigner())->assign(5, ['category' => [4, 'Neu']]);
}
public function testAssignFailureThrows(): void
{
Functions\when('wp_set_object_terms')->justReturn(new WP_Error('x', 'kaputt'));
$this->expectException(RuntimeException::class);
(new TermAssigner())->assign(5, ['category' => [4]]);
}
}
tests/Unit/Integrations/WordPress/PostGuardTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class PostGuardTest extends TestCase
{
private function schema(): ContentSchema
{
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->andReturnUsing(static fn (string $type): bool => in_array($type, ['page', 'post'], true));
return $schema;
}
public function testRejectsInvalidIds(): void
{
self::assertSame('lit_validation_failed', PostGuard::editable('abc', $this->schema())->get_error_code());
self::assertSame('lit_validation_failed', PostGuard::editable(-3, $this->schema())->get_error_code());
}
public function testMissingOrUnmanagedPostsAreNotFound(): void
{
Functions\when('get_post')->alias(static fn (int $id) => $id === 8 ? new WP_Post(['ID' => 8, 'post_type' => 'et_pb_layout']) : null);
self::assertSame('lit_not_found', PostGuard::editable(5, $this->schema())->get_error_code());
self::assertSame('lit_not_found', PostGuard::editable(8, $this->schema())->get_error_code());
}
public function testRequiresEditRightsAndReturnsThePost(): void
{
$post = new WP_Post(['ID' => 5, 'post_type' => 'page']);
Functions\when('get_post')->justReturn($post);
Functions\when('current_user_can')->alias(static fn (string $cap, int $id = 0): bool => $id === 5);
self::assertSame($post, PostGuard::editable('5', $this->schema()));
Functions\when('current_user_can')->justReturn(false);
self::assertSame('lit_forbidden', PostGuard::editable(5, $this->schema())->get_error_code());
}
public function testPostTypeChecksAvailabilityAndCapability(): void
{
$object = (object) ['cap' => (object) ['create_posts' => 'edit_pages']];
Functions\when('get_post_type_object')->justReturn($object);
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_pages');
self::assertSame('lit_not_found', PostGuard::postType('et_pb_layout', $this->schema(), 'create_posts')->get_error_code());
self::assertSame('lit_not_found', PostGuard::postType(null, $this->schema(), 'create_posts')->get_error_code());
self::assertSame($object, PostGuard::postType('page', $this->schema(), 'create_posts'));
Functions\when('current_user_can')->justReturn(false);
self::assertSame('lit_forbidden', PostGuard::postType('page', $this->schema(), 'create_posts')->get_error_code());
}
}
tests/Unit/Integrations/WordPress/RevisionPointTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\WordPress\RevisionPoint;
use LIT\MCP\Tests\Unit\TestCase;
use WP_Post;
final class RevisionPointTest extends TestCase
{
public function testCaptureSavesARevisionAndReturnsTheNewest(): void
{
$order = [];
Functions\expect('wp_save_post_revision')->once()->with(5)->andReturnUsing(static function () use (&$order) {
$order[] = 'save';
return 811;
});
Functions\expect('wp_get_post_revisions')->once()->with(5, ['posts_per_page' => 1])
->andReturnUsing(static function () use (&$order): array {
$order[] = 'latest';
return [811 => new WP_Post(['ID' => 811, 'post_type' => 'revision'])];
});
self::assertSame(811, RevisionPoint::capture(5));
self::assertSame(['save', 'latest'], $order);
}
public function testLatestIsNullWithoutRevisions(): void
{
Functions\when('wp_get_post_revisions')->justReturn([]);
self::assertNull(RevisionPoint::latest(5));
}
}
- Step 3: Run – muss fehlschlagen
Run: composer test -- --filter 'FieldValidatorTest|TermAssignerTest|PostGuardTest|RevisionPointTest'
Expected: FAIL, Klassen nicht gefunden.
- Step 4: Implementierung
src/Integrations/WordPress/Schema/FieldValidator.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Schema;
use DateTimeImmutable;
/**
* Checks field values against the detected schema before anything is written.
*/
final class FieldValidator
{
private const LIST_TYPES = ['relation', 'file', 'choice', 'array'];
/**
* @param array<string, FieldDefinition> $definitions
* @param array<string, mixed> $values
* @return array{values: array<string, mixed>, errors: array<string, string>, warnings: list<string>}
*/
public function validate(array $definitions, array $values, bool $isCreate): array
{
$clean = [];
$errors = [];
$warnings = [];
foreach ($values as $key => $value) {
$key = (string) $key;
$field = $definitions[$key] ?? null;
if ($field === null) {
$errors[$key] = 'Unbekanntes Feld; verfügbare Felder über lit-content-types abrufen.';
continue;
}
if ($field->status === FieldDefinition::READONLY) {
$errors[$key] = 'Feld ist schreibgeschützt.';
continue;
}
$error = $value === null ? null : $this->check($field, $value);
if ($error !== null) {
$errors[$key] = $error;
continue;
}
if ($field->status === FieldDefinition::UNKNOWN) {
$warnings[] = sprintf(
'Feld "%s" hat keinen bekannten Typ; der Wert wurde unverändert gespeichert.',
$key
);
}
$clean[$key] = $value;
}
foreach ($definitions as $key => $field) {
if (! $field->required || isset($errors[$key])) {
continue;
}
$provided = array_key_exists($key, $clean);
if ($isCreate && (! $provided || $this->isEmpty($clean[$key]))) {
$errors[$key] = 'Pflichtfeld fehlt.';
} elseif (! $isCreate && $provided && $this->isEmpty($clean[$key])) {
$errors[$key] = 'Pflichtfeld darf nicht geleert werden.';
}
}
return ['values' => $clean, 'errors' => $errors, 'warnings' => $warnings];
}
private function check(FieldDefinition $field, mixed $value): ?string
{
if ($field->multiple && in_array($field->type, self::LIST_TYPES, true)) {
if (! is_array($value) || ! array_is_list($value)) {
return 'Liste erwartet.';
}
if ($field->type === 'array') {
return null;
}
foreach ($value as $item) {
$error = $this->checkScalar($field, $item);
if ($error !== null) {
return $error;
}
}
return null;
}
return $this->checkScalar($field, $value);
}
private function checkScalar(FieldDefinition $field, mixed $value): ?string
{
return match ($field->type) {
'string', 'text' => is_string($value) ? null : 'Text erwartet.',
'integer' => is_int($value) ? null : 'Ganzzahl erwartet.',
'number' => is_int($value) || is_float($value) ? null : 'Zahl erwartet.',
'boolean' => is_bool($value) ? null : 'true oder false erwartet.',
'date' => $this->matchesFormat($value, 'Y-m-d') ? null : 'Datum im Format JJJJ-MM-TT erwartet.',
'datetime' => $this->matchesFormat($value, 'Y-m-d H:i:s')
? null
: 'Datum und Uhrzeit im Format JJJJ-MM-TT HH:MM:SS erwartet.',
'time' => $this->matchesFormat($value, 'H:i:s') ? null : 'Uhrzeit im Format HH:MM:SS erwartet.',
'choice' => $this->checkChoice($field, $value),
'relation', 'file' => $this->checkRelation($field, $value),
'array' => is_array($value) && array_is_list($value) ? null : 'Liste erwartet.',
'object' => is_array($value) ? null : 'Objekt erwartet.',
default => null,
};
}
private function checkChoice(FieldDefinition $field, mixed $value): ?string
{
$allowed = array_column($field->choices ?? [], 'value');
return is_string($value) && in_array($value, $allowed, true)
? null
: sprintf('Erlaubte Werte: %s.', implode(', ', $allowed));
}
private function checkRelation(FieldDefinition $field, mixed $value): ?string
{
if (! is_int($value) || $value <= 0) {
return 'ID (positive Ganzzahl) erwartet.';
}
$objectType = $field->related['object_type'] ?? '';
if ($objectType === 'media') {
return get_post_type($value) === 'attachment'
? null
: sprintf('%d ist kein Medium der Mediathek.', $value);
}
if ($objectType === 'post_type') {
$actual = get_post_type($value);
if ($actual === false) {
return sprintf('Eintrag %d existiert nicht.', $value);
}
$expected = $field->related['name'] ?? null;
if ($expected !== null && $actual !== $expected) {
return sprintf('Eintrag %d ist vom Typ %s, erwartet %s.', $value, $actual, $expected);
}
}
return null;
}
private function matchesFormat(mixed $value, string $format): bool
{
if (! is_string($value)) {
return false;
}
$parsed = DateTimeImmutable::createFromFormat('!' . $format, $value);
return $parsed !== false && $parsed->format($format) === $value;
}
private function isEmpty(mixed $value): bool
{
return $value === null || $value === '' || $value === [];
}
}
src/Integrations/WordPress/Schema/TermAssigner.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Schema;
use RuntimeException;
use WP_Term;
/**
* Resolves term input ({taxonomy: [id | name, …]}) and replaces a post's terms.
* resolve() never writes, so a whole call can be validated first (all or nothing).
*/
final class TermAssigner
{
/**
* @param array<string, mixed> $terms
* @return array{terms: array<string, list<int|string>>, errors: array<string, string>}
*/
public function resolve(string $postType, array $terms): array
{
$allowed = get_object_taxonomies($postType, 'names');
$resolved = [];
$errors = [];
foreach ($terms as $taxonomy => $items) {
$taxonomy = (string) $taxonomy;
if (! in_array($taxonomy, $allowed, true)) {
$errors[$taxonomy] = 'Taxonomie gehört nicht zu diesem Inhaltstyp.';
continue;
}
$object = get_taxonomy($taxonomy);
if (! is_object($object) || ! current_user_can($object->cap->assign_terms)) {
$errors[$taxonomy] = 'Keine Berechtigung, Begriffe dieser Taxonomie zuzuweisen.';
continue;
}
$result = $this->resolveItems($taxonomy, is_array($items) ? $items : [$items], $object);
if (is_string($result)) {
$errors[$taxonomy] = $result;
continue;
}
$resolved[$taxonomy] = $result;
}
return ['terms' => $resolved, 'errors' => $errors];
}
/**
* @param array<string, list<int|string>> $resolved Output of resolve().
* @throws RuntimeException
*/
public function assign(int $postId, array $resolved): void
{
foreach ($resolved as $taxonomy => $items) {
$ids = [];
foreach ($items as $item) {
if (is_int($item)) {
$ids[] = $item;
continue;
}
$created = wp_insert_term($item, $taxonomy);
if (is_wp_error($created)) {
throw new RuntimeException('Term could not be created: ' . $created->get_error_message());
}
$ids[] = (int) $created['term_id'];
}
$result = wp_set_object_terms($postId, $ids, $taxonomy, false);
if (is_wp_error($result)) {
throw new RuntimeException('Terms could not be assigned: ' . $result->get_error_message());
}
}
}
/**
* @param list<mixed> $items
* @return list<int|string>|string Resolved items or an error message.
*/
private function resolveItems(string $taxonomy, array $items, object $object): array|string
{
$resolved = [];
foreach ($items as $item) {
if (is_int($item)) {
if (! get_term($item, $taxonomy) instanceof WP_Term) {
return sprintf('Begriff %d existiert nicht.', $item);
}
$resolved[] = $item;
continue;
}
if (! is_string($item) || trim($item) === '') {
return 'Begriffe als ID (Zahl) oder Name (Text) angeben.';
}
$existing = term_exists(trim($item), $taxonomy);
if (is_array($existing)) {
$resolved[] = (int) $existing['term_id'];
continue;
}
if (! current_user_can($object->cap->edit_terms)) {
return sprintf('Begriff "%s" existiert nicht; keine Berechtigung, ihn anzulegen.', trim($item));
}
$resolved[] = trim($item);
}
return $resolved;
}
}
src/Integrations/WordPress/PostGuard.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
/**
* Loads entries and post types for abilities, including the capability checks
* of spec §4.5. Every error is ready to be returned from permission()/execute().
* Input is not schema-validated yet when permission() runs – hence the defensive id().
*/
final class PostGuard
{
public static function editable(mixed $postId, ContentSchema $schema, string $field = 'post_id'): WP_Post|WP_Error
{
$id = self::id($postId);
if ($id === null) {
return Errors::invalidInput(
sprintf('%s fehlt oder ist ungültig.', $field),
[$field => 'positive Ganzzahl erwartet']
);
}
$post = get_post($id);
if (! $post instanceof WP_Post || ! $schema->isManaged((string) $post->post_type)) {
return Errors::notFound(sprintf('Eintrag %d nicht gefunden (oder kein unterstützter Inhaltstyp).', $id));
}
if (! current_user_can('edit_post', $id)) {
return Errors::forbidden(sprintf('Keine Berechtigung, Eintrag %d zu bearbeiten.', $id));
}
return $post;
}
public static function postType(mixed $postType, ContentSchema $schema, string $cap): object|WP_Error
{
$name = is_string($postType) ? $postType : '';
$object = $name !== '' && $schema->isManaged($name) ? get_post_type_object($name) : null;
if (! is_object($object)) {
return Errors::notFound(
sprintf('Inhaltstyp "%s" ist nicht verfügbar.', $name),
'Verfügbare Inhaltstypen über lit-content-types abrufen.'
);
}
$capability = isset($object->cap->{$cap}) ? (string) $object->cap->{$cap} : $cap;
if (! current_user_can($capability)) {
return Errors::forbidden(sprintf('Keine Berechtigung für Inhaltstyp "%s".', $name));
}
return $object;
}
public static function id(mixed $value): ?int
{
if (is_int($value)) {
return $value > 0 ? $value : null;
}
if (is_string($value) && ctype_digit($value) && (int) $value > 0) {
return (int) $value;
}
return null;
}
}
src/Integrations/WordPress/RevisionPoint.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress;
/**
* Restore point before a change (spec §2 principle 7): WordPress stores the
* current state as a revision (only if it differs from the newest one) and the
* newest revision ID is returned as revision_id of the ToolResult.
* Note: revisions cover title, content and excerpt; meta and terms are not revisioned.
*/
final class RevisionPoint
{
public static function capture(int $postId): ?int
{
wp_save_post_revision($postId);
return self::latest($postId);
}
public static function latest(int $postId): ?int
{
$revisions = wp_get_post_revisions($postId, ['posts_per_page' => 1]);
$newest = is_array($revisions) ? reset($revisions) : false;
return is_object($newest) ? (int) $newest->ID : null;
}
}
- Step 5: Run – muss bestehen
Run: composer test && composer lint
Expected: PASS.
- Step 6: Commit
git add src/Integrations/WordPress/Schema/FieldValidator.php src/Integrations/WordPress/Schema/TermAssigner.php src/Integrations/WordPress/PostGuard.php src/Integrations/WordPress/RevisionPoint.php tests/Stubs tests/bootstrap.php tests/Unit/Integrations/WordPress
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: field validation, term resolution, post guard and revision points
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
Task 8: Lesen – PostPresenter, lit/content-query, lit/content-get
Files:
- Create:
src/Integrations/WordPress/Content/PostPresenter.php,src/Integrations/WordPress/Content/ContentQueryAbility.php,src/Integrations/WordPress/Content/ContentGetAbility.php - Modify:
src/Integrations/WordPress/WordPressIntegration.php(abilities()+ Imports),tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php(EXPECTED) - Test:
tests/Unit/Integrations/WordPress/Content/PostPresenterTest.php,tests/Unit/Integrations/WordPress/Content/ContentQueryAbilityTest.php,tests/Unit/Integrations/WordPress/Content/ContentGetAbilityTest.php
Interfaces:
-
Consumes:
ContentSchema::fields()/sourceOf()/taxonomies()/isManaged()(Task 4),PostGuard(Task 7),Cursor,Rev,JsonMap(Task 1). -
Produces:
PostPresenter(nicht final):__construct(ContentSchema $schema);summary(WP_Post $post): array{id, type, status, title, slug, parent, date, modified, url, edit_url, divi};full(WP_Post $post): array(Zusammenfassung +excerpt,content(nullbei Divi),menu_order,author{id,name},template,preview_url,terms(Map Taxonomie →list<{id,name,slug}>),fields(Map Schlüssel → Wert),featured_image({id,url,alt}|null),rev,hints);static usesDivi(WP_Post $post): bool;static summarySchema(): array,static fullSchema(): array(Output-Schemas).ContentQueryAbility:__construct(ContentSchema $schema, PostPresenter $presenter); Namelit/content-query; Output{items: list<summary>, next_cursor: ?string}; direkt; readonly, idempotent.ContentGetAbility:__construct(ContentSchema $schema, PostPresenter $presenter); Namelit/content-get; Input{post_id: int}; Outputfull; direkt; readonly, idempotent.
-
Step 1: Failing Tests
tests/Unit/Integrations/WordPress/Content/PostPresenterTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Content;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Integrations\WordPress\Content\PostPresenter;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
use LIT\MCP\Integrations\WordPress\Schema\FieldSource;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use stdClass;
use WP_Post;
use WP_Term;
final class PostPresenterTest extends TestCase
{
private function stubLinks(): void
{
Functions\when('get_permalink')->justReturn('https://kunde.de/team/');
Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=5&action=edit');
Functions\when('get_preview_post_link')->justReturn('https://kunde.de/?p=5&preview=true');
Functions\when('get_the_author_meta')->justReturn('Jan');
Functions\when('get_page_template_slug')->justReturn('');
}
public function testSummaryContainsCoreDataAndUrls(): void
{
$this->stubLinks();
Functions\when('get_post_meta')->justReturn('');
$post = new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Team', 'post_name' => 'team']);
$summary = (new PostPresenter(Mockery::mock(ContentSchema::class)))->summary($post);
self::assertSame(5, $summary['id']);
self::assertSame('Team', $summary['title']);
self::assertSame('team', $summary['slug']);
self::assertSame('https://kunde.de/team/', $summary['url']);
self::assertFalse($summary['divi']);
}
public function testFullReadsTermsFieldsAndFeaturedImage(): void
{
$this->stubLinks();
Functions\when('get_post_meta')->alias(static fn (int $id, string $key) => match ($key) {
'_wp_attachment_image_alt' => 'Porträt',
default => '',
});
Functions\when('wp_get_post_terms')->justReturn([new WP_Term(['term_id' => 4, 'name' => 'News', 'slug' => 'news'])]);
Functions\when('get_post_thumbnail_id')->justReturn(33);
Functions\when('wp_get_attachment_url')->justReturn('https://kunde.de/wp-content/uploads/foto.jpg');
$role = new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable');
$source = Mockery::mock(FieldSource::class);
$source->expects('read')->with(5, $role)->andReturn('Leitung');
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('taxonomies')->andReturn([['name' => 'category', 'label' => 'Kategorien', 'hierarchical' => true]]);
$schema->allows('fields')->andReturn(['role' => $role]);
$schema->allows('sourceOf')->andReturn($source);
$post = new WP_Post(['ID' => 5, 'post_type' => 'lit_person', 'post_content' => '<p>Hallo</p>', 'post_author' => '2']);
$full = (new PostPresenter($schema))->full($post);
self::assertSame('<p>Hallo</p>', $full['content']);
self::assertSame(['category' => [['id' => 4, 'name' => 'News', 'slug' => 'news']]], $full['terms']);
self::assertSame(['role' => 'Leitung'], $full['fields']);
self::assertSame(['id' => 33, 'url' => 'https://kunde.de/wp-content/uploads/foto.jpg', 'alt' => 'Porträt'], $full['featured_image']);
self::assertSame(['id' => 2, 'name' => 'Jan'], $full['author']);
self::assertSame(Rev::of('<p>Hallo</p>'), $full['rev']);
self::assertSame([], $full['hints']);
}
public function testDiviContentIsWithheldAndPointsToTheDiviOutline(): void
{
$this->stubLinks();
Functions\when('get_post_meta')->alias(static fn (int $id, string $key) => $key === '_et_pb_use_builder' ? 'on' : '');
Functions\when('get_post_thumbnail_id')->justReturn(0);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('taxonomies')->andReturn([]);
$schema->allows('fields')->andReturn([]);
$post = new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_content' => '<!-- wp:divi/section /-->']);
$full = (new PostPresenter($schema))->full($post);
self::assertTrue($full['divi']);
self::assertNull($full['content']);
self::assertNull($full['featured_image']);
self::assertStringContainsString('lit-divi-outline', $full['hints'][0]);
self::assertInstanceOf(stdClass::class, $full['terms']);
self::assertInstanceOf(stdClass::class, $full['fields']);
}
public function testDiviIsAlsoDetectedFromBlockMarkup(): void
{
Functions\when('get_post_meta')->justReturn('');
self::assertTrue(PostPresenter::usesDivi(new WP_Post(['post_content' => 'x <!-- wp:divi/text {} /--> y'])));
self::assertFalse(PostPresenter::usesDivi(new WP_Post(['post_content' => '<!-- wp:paragraph -->'])));
}
}
tests/Unit/Integrations/WordPress/Content/ContentQueryAbilityTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Content;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Cursor;
use LIT\MCP\Integrations\WordPress\Content\ContentQueryAbility;
use LIT\MCP\Integrations\WordPress\Content\PostPresenter;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class ContentQueryAbilityTest extends TestCase
{
private function ability(): ContentQueryAbility
{
$presenter = Mockery::mock(PostPresenter::class);
$presenter->allows('summary')->andReturnUsing(static fn (WP_Post $post): array => ['id' => $post->ID]);
return new ContentQueryAbility(Mockery::mock(ContentSchema::class), $presenter);
}
public function testBuildsTheQueryAndPaginates(): void
{
Functions\when('current_user_can')->justReturn(true);
Functions\expect('get_posts')->once()->with(Mockery::on(static function (array $args): bool {
return $args['post_type'] === 'lit_person'
&& $args['post_status'] === ['draft']
&& $args['s'] === 'Anna'
&& $args['posts_per_page'] === 3
&& $args['offset'] === 4
&& $args['orderby'] === 'title'
&& $args['order'] === 'ASC'
&& $args['tax_query'] === [
'relation' => 'OR',
['taxonomy' => 'category', 'field' => 'term_id', 'terms' => [4]],
['taxonomy' => 'category', 'field' => 'slug', 'terms' => ['news']],
]
&& $args['meta_query'] === [['key' => 'level', 'compare' => '=', 'value' => 'senior']]
&& $args['date_query'] === [['after' => '2026-01-01', 'inclusive' => true]];
}))->andReturn([new WP_Post(['ID' => 1]), new WP_Post(['ID' => 2]), new WP_Post(['ID' => 3])]);
$result = $this->ability()->execute([
'post_type' => 'lit_person',
'status' => ['draft'],
'search' => 'Anna',
'taxonomy' => ['taxonomy' => 'category', 'terms' => [4, 'news']],
'field' => ['key' => 'level', 'value' => 'senior'],
'date_after' => '2026-01-01',
'orderby' => 'title',
'order' => 'asc',
'limit' => 2,
'cursor' => Cursor::encode(4),
]);
self::assertSame([['id' => 1], ['id' => 2]], $result['items']);
self::assertSame(6, Cursor::decode($result['next_cursor']));
}
public function testExistsFilterHasNoValueAndLastPageHasNoCursor(): void
{
Functions\when('current_user_can')->justReturn(true);
Functions\expect('get_posts')->once()->with(Mockery::on(
static fn (array $args): bool => $args['meta_query'] === [['key' => 'role', 'compare' => 'EXISTS']]
&& $args['post_status'] === ['publish', 'draft', 'pending', 'private', 'future']
))->andReturn([new WP_Post(['ID' => 1])]);
$result = $this->ability()->execute(['post_type' => 'page', 'field' => ['key' => 'role', 'compare' => 'EXISTS']]);
self::assertNull($result['next_cursor']);
}
public function testPostsTheUserCannotEditAreDropped(): void
{
Functions\when('current_user_can')->alias(static fn (string $cap, int $id = 0): bool => $id !== 2);
Functions\when('get_posts')->justReturn([new WP_Post(['ID' => 1]), new WP_Post(['ID' => 2])]);
$result = $this->ability()->execute(['post_type' => 'page']);
self::assertSame([['id' => 1]], $result['items']);
}
public function testInvalidCursorIsRejected(): void
{
$result = $this->ability()->execute(['post_type' => 'page', 'cursor' => '???']);
self::assertSame('lit_validation_failed', $result->get_error_code());
}
public function testPermissionNeedsAManagedTypeAndEditRights(): void
{
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->andReturn(false);
$ability = new ContentQueryAbility($schema, Mockery::mock(PostPresenter::class));
self::assertSame('lit_not_found', $ability->permission(['post_type' => 'secret'])->get_error_code());
}
}
tests/Unit/Integrations/WordPress/Content/ContentGetAbilityTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Content;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\WordPress\Content\ContentGetAbility;
use LIT\MCP\Integrations\WordPress\Content\PostPresenter;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class ContentGetAbilityTest extends TestCase
{
public function testReturnsThePresentedPost(): void
{
$post = new WP_Post(['ID' => 5, 'post_type' => 'page']);
Functions\when('get_post')->justReturn($post);
Functions\when('current_user_can')->justReturn(true);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->andReturn(true);
$presenter = Mockery::mock(PostPresenter::class);
$presenter->expects('full')->with($post)->andReturn(['id' => 5]);
$ability = new ContentGetAbility($schema, $presenter);
self::assertTrue($ability->permission(['post_id' => 5]));
self::assertSame(['id' => 5], $ability->execute(['post_id' => 5]));
}
public function testMissingPostIsNotFound(): void
{
Functions\when('get_post')->justReturn(null);
$schema = Mockery::mock(ContentSchema::class);
$ability = new ContentGetAbility($schema, Mockery::mock(PostPresenter::class));
self::assertSame('lit_not_found', $ability->permission(['post_id' => 99])->get_error_code());
}
public function testOutputSchemaAllowsNullContentForDivi(): void
{
$ability = new ContentGetAbility(Mockery::mock(ContentSchema::class), Mockery::mock(PostPresenter::class));
self::assertSame(['string', 'null'], $ability->outputSchema()['properties']['content']['type']);
self::assertTrue($ability->isDirect());
}
}
In tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php die Konstante ersetzen:
private const EXPECTED = [
'lit/content-types',
'lit/content-query',
'lit/content-get',
];
- Step 2: Run – muss fehlschlagen
Run: composer test -- --filter 'PostPresenterTest|ContentQueryAbilityTest|ContentGetAbilityTest|WordPressIntegrationTest'
Expected: FAIL.
- Step 3: Implementierung
src/Integrations/WordPress/Content/PostPresenter.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Content;
use LIT\MCP\Abilities\JsonMap;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Post;
/**
* Output format of an entry for the content tools. Not final so abilities can be
* unit-tested with a mock.
*/
class PostPresenter
{
public const DIVI_HINT = 'Dieser Inhalt ist mit Divi gebaut. Die Divi-Struktur wird über lit-divi-outline '
. 'gelesen und mit den lit-divi-*-Tools bearbeitet; fehlen diese Tools, kann der Divi-Inhalt hier nicht '
. 'bearbeitet werden. content wird deshalb nicht ausgegeben; Titel, Auszug, Felder und Begriffe sind '
. 'über lit-content-update änderbar.';
public function __construct(private ContentSchema $schema)
{
}
/** @return array<string, mixed> */
public function summary(WP_Post $post): array
{
return [
'id' => (int) $post->ID,
'type' => (string) $post->post_type,
'status' => (string) $post->post_status,
'title' => (string) $post->post_title,
'slug' => (string) $post->post_name,
'parent' => (int) $post->post_parent,
'date' => (string) $post->post_date,
'modified' => (string) $post->post_modified,
'url' => (string) get_permalink($post),
'edit_url' => (string) get_edit_post_link($post->ID, 'raw'),
'divi' => self::usesDivi($post),
];
}
/** @return array<string, mixed> */
public function full(WP_Post $post): array
{
$divi = self::usesDivi($post);
return array_merge($this->summary($post), [
'excerpt' => (string) $post->post_excerpt,
'content' => $divi ? null : (string) $post->post_content,
'menu_order' => (int) $post->menu_order,
'author' => [
'id' => (int) $post->post_author,
'name' => (string) get_the_author_meta('display_name', (int) $post->post_author),
],
'template' => (string) get_page_template_slug($post),
'preview_url' => (string) get_preview_post_link($post),
'terms' => JsonMap::of($this->terms($post)),
'fields' => JsonMap::of($this->fields($post)),
'featured_image' => $this->featuredImage($post),
'rev' => Rev::of((string) $post->post_content),
'hints' => $divi ? [self::DIVI_HINT] : [],
]);
}
public static function usesDivi(WP_Post $post): bool
{
return get_post_meta((int) $post->ID, '_et_pb_use_builder', true) === 'on'
|| str_contains((string) $post->post_content, '<!-- wp:divi/');
}
/** @return array<string, mixed> */
public static function summarySchema(): array
{
return [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer'],
'type' => ['type' => 'string'],
'status' => ['type' => 'string'],
'title' => ['type' => 'string'],
'slug' => ['type' => 'string'],
'parent' => ['type' => 'integer'],
'date' => ['type' => 'string'],
'modified' => ['type' => 'string'],
'url' => ['type' => 'string'],
'edit_url' => ['type' => 'string'],
'divi' => ['type' => 'boolean'],
],
'required' => ['id', 'type', 'status', 'title', 'url', 'divi'],
];
}
/** @return array<string, mixed> */
public static function fullSchema(): array
{
$schema = self::summarySchema();
$schema['properties'] = array_merge($schema['properties'], [
'excerpt' => ['type' => 'string'],
'content' => ['type' => ['string', 'null']],
'menu_order' => ['type' => 'integer'],
'author' => ['type' => 'object'],
'template' => ['type' => 'string'],
'preview_url' => ['type' => 'string'],
'terms' => ['type' => 'object'],
'fields' => ['type' => 'object'],
'featured_image' => ['type' => ['object', 'null']],
'rev' => ['type' => 'string'],
'hints' => ['type' => 'array', 'items' => ['type' => 'string']],
]);
$schema['required'] = array_merge($schema['required'], ['content', 'terms', 'fields', 'rev', 'hints']);
return $schema;
}
/** @return array<string, list<array{id: int, name: string, slug: string}>> */
private function terms(WP_Post $post): array
{
$result = [];
foreach ($this->schema->taxonomies((string) $post->post_type) as $taxonomy) {
$terms = wp_get_post_terms((int) $post->ID, $taxonomy['name']);
if (! is_array($terms)) {
continue;
}
$result[$taxonomy['name']] = array_map(
static fn (object $term): array => [
'id' => (int) $term->term_id,
'name' => (string) $term->name,
'slug' => (string) $term->slug,
],
$terms
);
}
return $result;
}
/** @return array<string, mixed> */
private function fields(WP_Post $post): array
{
$result = [];
foreach ($this->schema->fields((string) $post->post_type) as $key => $field) {
$result[$key] = $this->schema->sourceOf($field)->read((int) $post->ID, $field);
}
return $result;
}
/** @return array{id: int, url: string, alt: string}|null */
private function featuredImage(WP_Post $post): ?array
{
$id = (int) get_post_thumbnail_id($post);
if ($id === 0) {
return null;
}
return [
'id' => $id,
'url' => (string) wp_get_attachment_url($id),
'alt' => (string) get_post_meta($id, '_wp_attachment_image_alt', true),
];
}
}
src/Integrations/WordPress/Content/ContentQueryAbility.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Content;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Cursor;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
final class ContentQueryAbility implements Ability
{
private const STATUSES = ['publish', 'draft', 'pending', 'private', 'future'];
private const DATE_PATTERN = '^\d{4}-\d{2}-\d{2}$';
public function __construct(private ContentSchema $schema, private PostPresenter $presenter)
{
}
public function name(): string
{
return 'lit/content-query';
}
public function label(): string
{
return 'Inhalte suchen';
}
public function description(): string
{
return 'Sucht Einträge eines Inhaltstyps. Filter: Status, Volltext, Taxonomie-Begriffe (IDs oder Slugs), '
. 'Feldwert, Datum, übergeordneter Eintrag; sortierbar; Cursor-Paginierung. Liefert Kurzdaten '
. '(ID, Titel, Status, URLs, Divi ja/nein) – Details über lit-content-get.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => array_merge([
'post_type' => ['type' => 'string', 'description' => 'Inhaltstyp, z. B. page oder post.'],
'status' => [
'type' => 'array',
'items' => ['type' => 'string', 'enum' => self::STATUSES],
'description' => 'Standard: alle außer Papierkorb.',
],
'search' => ['type' => 'string'],
'taxonomy' => [
'type' => 'object',
'properties' => [
'taxonomy' => ['type' => 'string'],
'terms' => ['type' => 'array', 'minItems' => 1, 'items' => ['type' => ['integer', 'string']]],
],
'required' => ['taxonomy', 'terms'],
'additionalProperties' => false,
],
'field' => [
'type' => 'object',
'properties' => [
'key' => ['type' => 'string'],
'value' => ['type' => ['string', 'number', 'boolean']],
'compare' => ['type' => 'string', 'enum' => ['=', '!=', 'LIKE', 'EXISTS', 'NOT EXISTS']],
],
'required' => ['key'],
'additionalProperties' => false,
],
'date_after' => ['type' => 'string', 'pattern' => self::DATE_PATTERN],
'date_before' => ['type' => 'string', 'pattern' => self::DATE_PATTERN],
'parent' => ['type' => 'integer', 'minimum' => 0],
'orderby' => ['type' => 'string', 'enum' => ['date', 'modified', 'title', 'menu_order']],
'order' => ['type' => 'string', 'enum' => ['asc', 'desc']],
], Cursor::inputProperties()),
'required' => ['post_type'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return [
'type' => 'object',
'properties' => [
'items' => ['type' => 'array', 'items' => PostPresenter::summarySchema()],
'next_cursor' => ['type' => ['string', 'null']],
],
'required' => ['items', 'next_cursor'],
];
}
public function annotations(): array
{
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return true;
}
public function permission(array $input): bool|WP_Error
{
$type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts');
return $type instanceof WP_Error ? $type : true;
}
public function execute(array $input): mixed
{
$offset = Cursor::decode(isset($input['cursor']) ? (string) $input['cursor'] : null);
if ($offset === null) {
return Cursor::invalid();
}
$limit = Cursor::limit($input['limit'] ?? null);
$posts = get_posts(array_merge($this->queryArgs($input), [
'posts_per_page' => $limit + 1,
'offset' => $offset,
]));
$page = Cursor::page(array_values($posts), $limit, $offset);
$items = [];
foreach ($page['items'] as $post) {
if ($post instanceof WP_Post && current_user_can('edit_post', (int) $post->ID)) {
$items[] = $this->presenter->summary($post);
}
}
return ['items' => $items, 'next_cursor' => $page['next_cursor']];
}
/**
* @param array<string, mixed> $input
* @return array<string, mixed>
*/
private function queryArgs(array $input): array
{
$args = [
'post_type' => (string) $input['post_type'],
'post_status' => $input['status'] ?? self::STATUSES,
'orderby' => (string) ($input['orderby'] ?? 'date'),
'order' => strtoupper((string) ($input['order'] ?? 'desc')),
'no_found_rows' => true,
'ignore_sticky_posts' => true,
'suppress_filters' => false,
];
if (isset($input['search']) && $input['search'] !== '') {
$args['s'] = (string) $input['search'];
}
if (isset($input['parent'])) {
$args['post_parent'] = (int) $input['parent'];
}
if (isset($input['taxonomy']) && is_array($input['taxonomy'])) {
$args['tax_query'] = $this->taxQuery($input['taxonomy']);
}
if (isset($input['field']) && is_array($input['field'])) {
$compare = (string) ($input['field']['compare'] ?? '=');
$clause = ['key' => (string) $input['field']['key'], 'compare' => $compare];
if (! in_array($compare, ['EXISTS', 'NOT EXISTS'], true)) {
$clause['value'] = $input['field']['value'] ?? '';
}
$args['meta_query'] = [$clause];
}
$date = [];
if (isset($input['date_after'])) {
$date['after'] = (string) $input['date_after'];
}
if (isset($input['date_before'])) {
$date['before'] = (string) $input['date_before'];
}
if ($date !== []) {
$args['date_query'] = [array_merge($date, ['inclusive' => true])];
}
return $args;
}
/**
* @param array<string, mixed> $filter
* @return array<int|string, mixed>
*/
private function taxQuery(array $filter): array
{
$taxonomy = (string) $filter['taxonomy'];
$terms = (array) $filter['terms'];
$ids = array_values(array_filter($terms, 'is_int'));
$slugs = array_values(array_filter($terms, 'is_string'));
$clauses = [];
if ($ids !== []) {
$clauses[] = ['taxonomy' => $taxonomy, 'field' => 'term_id', 'terms' => $ids];
}
if ($slugs !== []) {
$clauses[] = ['taxonomy' => $taxonomy, 'field' => 'slug', 'terms' => $slugs];
}
return count($clauses) > 1 ? array_merge(['relation' => 'OR'], $clauses) : $clauses;
}
}
src/Integrations/WordPress/Content/ContentGetAbility.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Content;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
final class ContentGetAbility implements Ability
{
public function __construct(private ContentSchema $schema, private PostPresenter $presenter)
{
}
public function name(): string
{
return 'lit/content-get';
}
public function label(): string
{
return 'Inhalt lesen';
}
public function description(): string
{
return 'Liest einen Eintrag vollständig: Kerndaten, Inhalt, Begriffe, alle Felder (Pods/Meta), '
. 'Beitragsbild, rev (für lit-content-update). Bei Divi-Inhalten wird content nicht ausgegeben; '
. 'hints verweist dann auf lit-divi-outline.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => ['post_id' => ['type' => 'integer', 'minimum' => 1]],
'required' => ['post_id'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return PostPresenter::fullSchema();
}
public function annotations(): array
{
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return true;
}
public function permission(array $input): bool|WP_Error
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
return $post instanceof WP_Error ? $post : true;
}
public function execute(array $input): mixed
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
if ($post instanceof WP_Error) {
return $post;
}
return $this->presenter->full($post);
}
}
src/Integrations/WordPress/WordPressIntegration.php: Imports ergänzen
use LIT\MCP\Integrations\WordPress\Content\ContentGetAbility;
use LIT\MCP\Integrations\WordPress\Content\ContentQueryAbility;
use LIT\MCP\Integrations\WordPress\Content\PostPresenter;
und abilities() ersetzen:
public function abilities(): array
{
$presenter = new PostPresenter($this->schema);
return [
new ContentTypesAbility($this->schema),
new ContentQueryAbility($this->schema, $presenter),
new ContentGetAbility($this->schema, $presenter),
];
}
- Step 4: Run – muss bestehen
Run: composer test && composer lint
Expected: PASS.
- Step 5: Commit
git add src/Integrations/WordPress tests/Unit/Integrations/WordPress
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: content-query and content-get tools
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
Task 9: Schreiben – PostWriter, lit/content-create, lit/content-update
Files:
- Create:
src/Integrations/WordPress/Content/PostWriter.php,src/Integrations/WordPress/Content/ContentCreateAbility.php,src/Integrations/WordPress/Content/ContentUpdateAbility.php - Modify:
src/Integrations/WordPress/WordPressIntegration.php,tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php - Test:
tests/Unit/Integrations/WordPress/Content/PostWriterTest.php,tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php,tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php
Interfaces:
-
Consumes:
ContentSchema,FieldValidator,TermAssigner,PostGuard,RevisionPoint(Tasks 4, 7),PostPresenter::usesDivi()(Task 8),ChangeLog::record()(Task 3),ToolResult,Rev,Errors(Task 1). -
Produces:
PostWriter(nicht final):__construct(ContentSchema $schema, FieldValidator $validator, TermAssigner $terms);prepare(string $postType, array $input, bool $isCreate): array{fields: array, terms: array, warnings: list<string>}|WP_Error(schreibt nichts; Fehlerpfadefields.<key>,terms.<taxonomie>,featured_image,parent);postData(array $input): array(Eingabe →wp_insert_post-Spaltenpost_title,post_content,post_excerpt,post_name,post_parent,menu_order,page_template);hasExtras(array $input): bool;writeExtras(int $postId, string $postType, array $prepared, array $input): void(Begriffe, Felder je Quelle, Beitragsbild; wirftRuntimeException);static inputProperties(): array(gemeinsame Input-Properties).ContentCreateAbility:__construct(ContentSchema $schema, PostWriter $writer, ChangeLog $log); Namelit/content-create; Inputpost_type,title(Pflicht) +inputProperties(); keinstatus-Parameter; OutputToolResult::schema(); direkt; nicht idempotent.ContentUpdateAbility: gleicher Konstruktor; Namelit/content-update; Inputpost_id(Pflicht),rev,title+inputProperties(); OutputToolResult::schema(); direkt; idempotent.- Action
lit_mcp_after_save(int $postId, string $ability, array $input) nach jedem erfolgreichen Schreiben.
-
Step 1: Failing Tests
tests/Unit/Integrations/WordPress/Content/PostWriterTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Content;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\WordPress\Content\PostWriter;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
use LIT\MCP\Integrations\WordPress\Schema\FieldSource;
use LIT\MCP\Integrations\WordPress\Schema\FieldValidator;
use LIT\MCP\Integrations\WordPress\Schema\TermAssigner;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class PostWriterTest extends TestCase
{
public function testPrepareCollectsAllErrorsWithPaths(): void
{
Functions\when('get_object_taxonomies')->justReturn([]);
Functions\when('post_type_supports')->justReturn(false);
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 3, 'post_type' => 'post']));
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->andReturn([
'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable', true),
]);
$writer = new PostWriter($schema, new FieldValidator(), new TermAssigner());
$result = $writer->prepare('lit_person', [
'terms' => ['genre' => [1]],
'featured_image' => 9,
'parent' => 3,
], true);
$fields = $result->get_error_data()['fields'];
self::assertSame('lit_validation_failed', $result->get_error_code());
self::assertSame('Pflichtfeld fehlt.', $fields['fields.role']);
self::assertSame('Taxonomie gehört nicht zu diesem Inhaltstyp.', $fields['terms.genre']);
self::assertSame('Inhaltstyp unterstützt kein Beitragsbild.', $fields['featured_image']);
self::assertStringContainsString('denselben Inhaltstyp', $fields['parent']);
}
public function testPrepareReturnsValuesTermsAndWarnings(): void
{
Functions\when('get_object_taxonomies')->justReturn([]);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->andReturn(['city' => new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown')]);
$result = (new PostWriter($schema, new FieldValidator(), new TermAssigner()))
->prepare('page', ['fields' => ['city' => 'Trier']], false);
self::assertSame(['city' => 'Trier'], $result['fields']);
self::assertSame([], $result['terms']);
self::assertCount(1, $result['warnings']);
}
public function testPostDataMapsOnlyGivenKeys(): void
{
$writer = new PostWriter(Mockery::mock(ContentSchema::class), new FieldValidator(), new TermAssigner());
self::assertSame(
['post_title' => 'Team', 'post_name' => 'team', 'page_template' => ''],
$writer->postData(['title' => 'Team', 'slug' => 'team', 'template' => '', 'fields' => []])
);
self::assertTrue($writer->hasExtras(['featured_image' => null]));
self::assertFalse($writer->hasExtras(['title' => 'x', 'fields' => []]));
}
public function testWriteExtrasGroupsFieldsBySourceAndHandlesTheFeaturedImage(): void
{
$role = new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable');
$city = new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown');
$definitions = ['city' => $city, 'role' => $role];
$pods = Mockery::mock(FieldSource::class);
$pods->expects('write')->with(5, 'lit_person', ['role' => 'Leitung'], $definitions);
$sample = Mockery::mock(FieldSource::class);
$sample->expects('write')->with(5, 'lit_person', ['city' => 'Trier'], $definitions);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->andReturn($definitions);
$schema->allows('sourceOf')->andReturnUsing(static fn (FieldDefinition $f) => $f->source === 'pods' ? $pods : $sample);
Functions\expect('delete_post_thumbnail')->once()->with(5);
(new PostWriter($schema, new FieldValidator(), new TermAssigner()))->writeExtras(
5,
'lit_person',
['fields' => ['role' => 'Leitung', 'city' => 'Trier'], 'terms' => [], 'warnings' => []],
['featured_image' => 0]
);
}
}
tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Content;
use Brain\Monkey\Actions;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility;
use LIT\MCP\Integrations\WordPress\Content\PostWriter;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class ContentCreateAbilityTest extends TestCase
{
private function schema(): ContentSchema
{
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['create_posts' => 'edit_pages']]);
Functions\when('current_user_can')->justReturn(true);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->andReturn(true);
return $schema;
}
public function testInvalidInputWritesNothing(): void
{
$writer = Mockery::mock(PostWriter::class);
$writer->allows('prepare')->andReturn(Errors::invalidInput('ungültig', ['fields.role' => 'Pflichtfeld fehlt.']));
Functions\expect('wp_insert_post')->never();
$result = (new ContentCreateAbility($this->schema(), $writer, Mockery::mock(ChangeLog::class)))
->execute(['post_type' => 'lit_person', 'title' => 'Anna']);
self::assertSame('lit_validation_failed', $result->get_error_code());
}
public function testCreatesADraftWritesExtrasLogsAndReturnsAToolResult(): void
{
$schema = $this->schema();
$prepared = ['fields' => ['role' => 'Leitung'], 'terms' => [], 'warnings' => ['w']];
$input = ['post_type' => 'lit_person', 'title' => 'Anna', 'fields' => ['role' => 'Leitung']];
$writer = Mockery::mock(PostWriter::class);
$writer->allows('prepare')->with('lit_person', $input, true)->andReturn($prepared);
$writer->allows('postData')->andReturn(['post_title' => 'Anna']);
$writer->expects('writeExtras')->with(42, 'lit_person', $prepared, $input);
Functions\when('get_current_user_id')->justReturn(3);
Functions\expect('wp_insert_post')->once()->with([
'post_title' => 'Anna',
'post_type' => 'lit_person',
'post_status' => 'draft',
'post_author' => 3,
], true)->andReturn(42);
Actions\expectDone('lit_mcp_after_save')->once()->with(42, 'lit/content-create', $input);
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 42, 'post_title' => 'Anna', 'post_type' => 'lit_person']));
Functions\when('get_preview_post_link')->justReturn('https://kunde.de/?p=42&preview=true');
Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=42&action=edit');
$log = Mockery::mock(ChangeLog::class);
$log->expects('record')->with('lit/content-create', 'lit_person', '42', 'Entwurf "Anna" angelegt')->andReturn(7);
$result = (new ContentCreateAbility($schema, $writer, $log))->execute($input);
self::assertTrue($result['ok']);
self::assertSame([['id' => '42', 'type' => 'lit_person', 'action' => 'created', 'label' => 'Anna']], $result['changed']);
self::assertSame(7, $result['log_id']);
self::assertSame(['w'], $result['warnings']);
self::assertStringContainsString('Entwurf', $result['summary']);
self::assertNotNull($result['rev']);
}
public function testStatusIsNotAnInputAndPermissionNeedsCreateRights(): void
{
$schema = $this->schema();
$ability = new ContentCreateAbility($schema, Mockery::mock(PostWriter::class), Mockery::mock(ChangeLog::class));
self::assertArrayNotHasKey('status', $ability->inputSchema()['properties']);
self::assertFalse($ability->inputSchema()['additionalProperties']);
self::assertTrue($ability->permission(['post_type' => 'page']));
Functions\when('current_user_can')->justReturn(false);
self::assertSame('lit_forbidden', $ability->permission(['post_type' => 'page'])->get_error_code());
}
}
tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Content;
use Brain\Monkey\Actions;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\Content\ContentUpdateAbility;
use LIT\MCP\Integrations\WordPress\Content\PostWriter;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class ContentUpdateAbilityTest extends TestCase
{
private WP_Post $post;
protected function setUp(): void
{
parent::setUp();
$this->post = new WP_Post([
'ID' => 5,
'post_type' => 'page',
'post_status' => 'publish',
'post_title' => 'Team',
'post_content' => '<p>Alt</p>',
]);
Functions\when('get_post')->justReturn($this->post);
Functions\when('current_user_can')->justReturn(true);
}
private function schema(): ContentSchema
{
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->andReturn(true);
return $schema;
}
public function testStaleRevIsRejectedWithoutWriting(): void
{
Functions\expect('wp_update_post')->never();
$result = (new ContentUpdateAbility($this->schema(), Mockery::mock(PostWriter::class), Mockery::mock(ChangeLog::class)))
->execute(['post_id' => 5, 'rev' => 'stale', 'title' => 'Neu']);
self::assertSame('lit_rev_mismatch', $result->get_error_code());
}
public function testRawContentOnDiviPagesIsUnsupported(): void
{
Functions\when('get_post_meta')->justReturn('on');
$writer = Mockery::mock(PostWriter::class);
$writer->expects('prepare')->never();
$result = (new ContentUpdateAbility($this->schema(), $writer, Mockery::mock(ChangeLog::class)))
->execute(['post_id' => 5, 'content' => '<p>Neu</p>']);
self::assertSame('lit_unsupported', $result->get_error_code());
self::assertStringContainsString('lit-divi-outline', $result->get_error_message());
}
public function testNothingToChangeIsAValidationError(): void
{
$writer = Mockery::mock(PostWriter::class);
$writer->allows('postData')->andReturn([]);
$writer->allows('hasExtras')->andReturn(false);
$result = (new ContentUpdateAbility($this->schema(), $writer, Mockery::mock(ChangeLog::class)))
->execute(['post_id' => 5]);
self::assertSame('lit_validation_failed', $result->get_error_code());
}
public function testUpdateKeepsTheStatusAndCapturesARestorePointFirst(): void
{
$order = [];
$prepared = ['fields' => [], 'terms' => [], 'warnings' => []];
$input = ['post_id' => 5, 'rev' => Rev::of('<p>Alt</p>'), 'title' => 'Unser Team'];
$writer = Mockery::mock(PostWriter::class);
$writer->allows('postData')->andReturn(['post_title' => 'Unser Team']);
$writer->allows('hasExtras')->andReturn(false);
$writer->allows('prepare')->with('page', $input, false)->andReturn($prepared);
$writer->expects('writeExtras')->with(5, 'page', $prepared, $input);
Functions\expect('wp_save_post_revision')->once()->with(5)->andReturnUsing(static function () use (&$order) {
$order[] = 'revision';
return 811;
});
Functions\when('wp_get_post_revisions')->justReturn([811 => new WP_Post(['ID' => 811])]);
Functions\expect('wp_update_post')->once()->with(['post_title' => 'Unser Team', 'ID' => 5], true)
->andReturnUsing(static function () use (&$order): int {
$order[] = 'update';
return 5;
});
Actions\expectDone('lit_mcp_after_save')->once()->with(5, 'lit/content-update', $input);
Functions\when('get_preview_post_link')->justReturn('https://kunde.de/team/');
Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=5&action=edit');
$log = Mockery::mock(ChangeLog::class);
$log->expects('record')->with('lit/content-update', 'page', '5', Mockery::type('string'), 811)->andReturn(4711);
$result = (new ContentUpdateAbility($this->schema(), $writer, $log))->execute($input);
self::assertSame(['revision', 'update'], $order);
self::assertSame(811, $result['revision_id']);
self::assertSame(4711, $result['log_id']);
self::assertSame('updated', $result['changed'][0]['action']);
self::assertStringContainsString('Status unverändert: publish', $result['summary']);
}
}
In WordPressIntegrationTest die Konstante ersetzen:
private const EXPECTED = [
'lit/content-types',
'lit/content-query',
'lit/content-get',
'lit/content-create',
'lit/content-update',
];
- Step 2: Run – muss fehlschlagen
Run: composer test -- --filter 'PostWriterTest|ContentCreateAbilityTest|ContentUpdateAbilityTest|WordPressIntegrationTest'
Expected: FAIL.
- Step 3: Implementierung
src/Integrations/WordPress/Content/PostWriter.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Content;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Schema\FieldValidator;
use LIT\MCP\Integrations\WordPress\Schema\TermAssigner;
use RuntimeException;
use WP_Error;
use WP_Post;
/**
* Shared validation and write logic of lit/content-create and lit/content-update.
* prepare() validates everything before the first write (all or nothing, spec §8.2).
* Not final so the abilities can be unit-tested with a mock.
*/
class PostWriter
{
private const POST_FIELDS = [
'title' => 'post_title',
'content' => 'post_content',
'excerpt' => 'post_excerpt',
'slug' => 'post_name',
'parent' => 'post_parent',
'menu_order' => 'menu_order',
'template' => 'page_template',
];
public function __construct(
private ContentSchema $schema,
private FieldValidator $validator,
private TermAssigner $terms
) {
}
/** @return array<string, array<string, mixed>> */
public static function inputProperties(): array
{
return [
'content' => ['type' => 'string', 'description' => 'HTML bzw. Block-Markup (nicht für Divi-Inhalte).'],
'excerpt' => ['type' => 'string'],
'slug' => ['type' => 'string'],
'parent' => ['type' => 'integer', 'minimum' => 0],
'menu_order' => ['type' => 'integer'],
'template' => ['type' => 'string', 'description' => 'Seiten-Template (Dateiname), leer = Standard.'],
'terms' => [
'type' => 'object',
'additionalProperties' => ['type' => 'array', 'items' => ['type' => ['integer', 'string']]],
'description' => 'Je Taxonomie die vollständige Liste der Begriffe (IDs oder Namen); '
. 'ersetzt die bisherige Zuordnung dieser Taxonomie.',
],
'fields' => [
'type' => 'object',
'description' => 'Feldwerte nach Schlüssel (siehe lit-content-types); null leert ein Feld.',
],
'featured_image' => [
'type' => ['integer', 'null'],
'minimum' => 0,
'description' => 'Anhang-ID des Beitragsbilds; 0 oder null entfernt es.',
],
];
}
/**
* @param array<string, mixed> $input
* @return array<string, mixed>|WP_Error Keys fields, terms (taxonomy => list<int|string>), warnings.
*/
public function prepare(string $postType, array $input, bool $isCreate): array|WP_Error
{
$errors = [];
$fields = $this->validator->validate(
$this->schema->fields($postType),
is_array($input['fields'] ?? null) ? $input['fields'] : [],
$isCreate
);
foreach ($fields['errors'] as $key => $message) {
$errors['fields.' . $key] = $message;
}
$terms = $this->terms->resolve($postType, is_array($input['terms'] ?? null) ? $input['terms'] : []);
foreach ($terms['errors'] as $taxonomy => $message) {
$errors['terms.' . $taxonomy] = $message;
}
$imageError = $this->checkFeaturedImage($postType, $input);
if ($imageError !== null) {
$errors['featured_image'] = $imageError;
}
$parentError = $this->checkParent($postType, $input);
if ($parentError !== null) {
$errors['parent'] = $parentError;
}
if ($errors !== []) {
return Errors::invalidInput('Eingaben sind ungültig; nichts wurde gespeichert.', $errors);
}
return ['fields' => $fields['values'], 'terms' => $terms['terms'], 'warnings' => $fields['warnings']];
}
/**
* @param array<string, mixed> $input
* @return array<string, mixed>
*/
public function postData(array $input): array
{
$data = [];
foreach (self::POST_FIELDS as $key => $column) {
if (array_key_exists($key, $input)) {
$data[$column] = $input[$key];
}
}
return $data;
}
/** @param array<string, mixed> $input */
public function hasExtras(array $input): bool
{
return ! empty($input['fields']) || ! empty($input['terms']) || array_key_exists('featured_image', $input);
}
/**
* @param array{fields: array<string, mixed>, terms: array<string, list<int|string>>} $prepared
* @param array<string, mixed> $input
* @throws RuntimeException
*/
public function writeExtras(int $postId, string $postType, array $prepared, array $input): void
{
$this->terms->assign($postId, $prepared['terms']);
$definitions = $this->schema->fields($postType);
$bySource = [];
foreach ($prepared['fields'] as $key => $value) {
$bySource[$definitions[$key]->source][$key] = $value;
}
foreach ($bySource as $values) {
$first = $definitions[(string) array_key_first($values)];
$this->schema->sourceOf($first)->write($postId, $postType, $values, $definitions);
}
if (array_key_exists('featured_image', $input)) {
$imageId = (int) ($input['featured_image'] ?? 0);
if ($imageId > 0) {
set_post_thumbnail($postId, $imageId);
} else {
delete_post_thumbnail($postId);
}
}
}
/** @param array<string, mixed> $input */
private function checkFeaturedImage(string $postType, array $input): ?string
{
$imageId = (int) ($input['featured_image'] ?? 0);
if ($imageId === 0) {
return null;
}
if (! post_type_supports($postType, 'thumbnail')) {
return 'Inhaltstyp unterstützt kein Beitragsbild.';
}
return wp_attachment_is_image($imageId) ? null : 'Kein Bild aus der Mediathek.';
}
/** @param array<string, mixed> $input */
private function checkParent(string $postType, array $input): ?string
{
$parentId = (int) ($input['parent'] ?? 0);
if ($parentId === 0) {
return null;
}
$parent = get_post($parentId);
return $parent instanceof WP_Post && $parent->post_type === $postType
? null
: 'Übergeordneter Eintrag muss existieren und denselben Inhaltstyp haben.';
}
}
src/Integrations/WordPress/Content/ContentCreateAbility.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Content;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
final class ContentCreateAbility implements Ability
{
public function __construct(
private ContentSchema $schema,
private PostWriter $writer,
private ChangeLog $log
) {
}
public function name(): string
{
return 'lit/content-create';
}
public function label(): string
{
return 'Inhalt anlegen';
}
public function description(): string
{
return 'Legt einen neuen Eintrag an – immer als Entwurf (veröffentlichen nur über lit-post-publish). '
. 'Kerndaten, Begriffe, Felder (gegen das Schema aus lit-content-types geprüft) und Beitragsbild '
. 'in einem Schritt; bei einem Fehler wird nichts angelegt.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => array_merge([
'post_type' => ['type' => 'string'],
'title' => ['type' => 'string', 'minLength' => 1],
], PostWriter::inputProperties()),
'required' => ['post_type', 'title'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema();
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => false, 'idempotent' => false];
}
public function isDirect(): bool
{
return true;
}
public function permission(array $input): bool|WP_Error
{
$type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'create_posts');
return $type instanceof WP_Error ? $type : true;
}
public function execute(array $input): mixed
{
$type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'create_posts');
if ($type instanceof WP_Error) {
return $type;
}
$postType = (string) $input['post_type'];
$prepared = $this->writer->prepare($postType, $input, true);
if ($prepared instanceof WP_Error) {
return $prepared;
}
$data = array_merge($this->writer->postData($input), [
'post_type' => $postType,
'post_status' => 'draft',
'post_author' => get_current_user_id(),
]);
$postId = wp_insert_post(wp_slash($data), true);
if ($postId instanceof WP_Error) {
return Errors::invalidInput('WordPress hat den Eintrag abgelehnt: ' . $postId->get_error_message());
}
$postId = (int) $postId;
$this->writer->writeExtras($postId, $postType, $prepared, $input);
do_action('lit_mcp_after_save', $postId, $this->name(), $input);
$post = get_post($postId);
$title = $post instanceof WP_Post ? (string) $post->post_title : (string) $input['title'];
$summary = sprintf('Entwurf "%s" angelegt', $title);
$logId = $this->log->record($this->name(), $postType, (string) $postId, $summary);
return ToolResult::ok(sprintf('"%s" als Entwurf angelegt (ID %d).', $title, $postId))
->changed((string) $postId, $postType, 'created', $title)
->rev(Rev::of($post instanceof WP_Post ? (string) $post->post_content : ''))
->logId($logId)
->urls((string) get_preview_post_link($postId), (string) get_edit_post_link($postId, 'raw'))
->warnings($prepared['warnings'])
->toArray();
}
}
src/Integrations/WordPress/Content/ContentUpdateAbility.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Content;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\RevisionPoint;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
final class ContentUpdateAbility implements Ability
{
private const CHANGE_KEYS = [
'title', 'content', 'excerpt', 'slug', 'parent', 'menu_order', 'template', 'terms', 'fields', 'featured_image',
];
public function __construct(
private ContentSchema $schema,
private PostWriter $writer,
private ChangeLog $log
) {
}
public function name(): string
{
return 'lit/content-update';
}
public function label(): string
{
return 'Inhalt ändern';
}
public function description(): string
{
return 'Ändert Kerndaten, Begriffe, Felder oder Beitragsbild eines Eintrags; der Status bleibt '
. 'unverändert (veröffentlichte Inhalte werden live geändert, vorher entsteht eine Revision). '
. 'rev aus lit-content-get mitgeben, um parallele Änderungen nicht zu überschreiben. '
. 'content von Divi-Seiten ist hier nicht änderbar.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => array_merge([
'post_id' => ['type' => 'integer', 'minimum' => 1],
'rev' => ['type' => 'string', 'description' => 'rev aus lit-content-get (optimistische Sperre).'],
'title' => ['type' => 'string', 'minLength' => 1],
], PostWriter::inputProperties()),
'required' => ['post_id'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema();
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return true;
}
public function permission(array $input): bool|WP_Error
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
return $post instanceof WP_Error ? $post : true;
}
public function execute(array $input): mixed
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
if ($post instanceof WP_Error) {
return $post;
}
if (isset($input['rev']) && $input['rev'] !== Rev::of((string) $post->post_content)) {
return Errors::revMismatch();
}
if (array_key_exists('content', $input) && PostPresenter::usesDivi($post)) {
return Errors::unsupported(
'Divi-Inhalte können nicht als Rohtext überschrieben werden; nichts wurde gespeichert.',
'Divi-Struktur über lit-divi-outline und die lit-divi-*-Tools bearbeiten; Titel, Auszug, '
. 'Felder und Begriffe hier ohne content ändern.'
);
}
$data = $this->writer->postData($input);
if ($data === [] && ! $this->writer->hasExtras($input)) {
return Errors::invalidInput(
'Nichts zu ändern.',
[],
'Mindestens eines angeben: ' . implode(', ', self::CHANGE_KEYS) . '.'
);
}
$postType = (string) $post->post_type;
$prepared = $this->writer->prepare($postType, $input, false);
if ($prepared instanceof WP_Error) {
return $prepared;
}
$postId = (int) $post->ID;
$revisionId = RevisionPoint::capture($postId);
if ($data !== []) {
$result = wp_update_post(wp_slash(array_merge($data, ['ID' => $postId])), true);
if ($result instanceof WP_Error) {
return Errors::invalidInput('WordPress hat die Änderung abgelehnt: ' . $result->get_error_message());
}
}
$this->writer->writeExtras($postId, $postType, $prepared, $input);
do_action('lit_mcp_after_save', $postId, $this->name(), $input);
$updated = get_post($postId);
$updated = $updated instanceof WP_Post ? $updated : $post;
$changedKeys = array_keys(array_intersect_key($input, array_flip(self::CHANGE_KEYS)));
$summary = sprintf('"%s" geändert: %s', $updated->post_title, implode(', ', $changedKeys));
$logId = $this->log->record($this->name(), $postType, (string) $postId, $summary, $revisionId);
return ToolResult::ok(sprintf('%s (Status unverändert: %s).', $summary, $updated->post_status))
->changed((string) $postId, $postType, 'updated', (string) $updated->post_title)
->rev(Rev::of((string) $updated->post_content))
->revisionId($revisionId)
->logId($logId)
->urls((string) get_preview_post_link($updated), (string) get_edit_post_link($postId, 'raw'))
->warnings($prepared['warnings'])
->toArray();
}
}
src/Integrations/WordPress/WordPressIntegration.php: Imports ergänzen
use LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility;
use LIT\MCP\Integrations\WordPress\Content\ContentUpdateAbility;
use LIT\MCP\Integrations\WordPress\Content\PostWriter;
use LIT\MCP\Integrations\WordPress\Schema\FieldValidator;
use LIT\MCP\Integrations\WordPress\Schema\TermAssigner;
und abilities() ersetzen:
public function abilities(): array
{
$presenter = new PostPresenter($this->schema);
$writer = new PostWriter($this->schema, new FieldValidator(), new TermAssigner());
return [
new ContentTypesAbility($this->schema),
new ContentQueryAbility($this->schema, $presenter),
new ContentGetAbility($this->schema, $presenter),
new ContentCreateAbility($this->schema, $writer, $this->log),
new ContentUpdateAbility($this->schema, $writer, $this->log),
];
}
- Step 4: Run – muss bestehen
Run: composer test && composer lint
Expected: PASS.
- Step 5: Commit
git add src/Integrations/WordPress tests/Unit/Integrations/WordPress
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: content-create and content-update with schema validation
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
Task 10: Status – lit/post-publish, lit/post-unpublish, lit/post-schedule, lit/post-trash
Statuswechsel sind eigene, explizite Tools (Spec §2 Prinzip 5, §6.2). post-trash legt nur in den Papierkorb (Spec §10).
Files:
- Create:
src/Integrations/WordPress/Status/PostPublishAbility.php,src/Integrations/WordPress/Status/PostUnpublishAbility.php,src/Integrations/WordPress/Status/PostScheduleAbility.php,src/Integrations/WordPress/Status/PostTrashAbility.php - Modify:
src/Integrations/WordPress/WordPressIntegration.php,tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php - Test:
tests/Unit/Integrations/WordPress/Status/PostPublishAbilityTest.php,tests/Unit/Integrations/WordPress/Status/PostUnpublishAbilityTest.php,tests/Unit/Integrations/WordPress/Status/PostScheduleAbilityTest.php,tests/Unit/Integrations/WordPress/Status/PostTrashAbilityTest.php
Interfaces:
-
Consumes:
PostGuard,ContentSchema,ChangeLog,ToolResult,Errors. -
Produces (alle
__construct(ContentSchema $schema, ChangeLog $log), OutputToolResult::schema()):PostPublishAbility–lit/post-publish, Input{post_id}; direkt; idempotent; Rechtpublish_post; setzt beifuture/zukunftsdatiertem Eintrag das Datum auf jetzt;changed.action=published.PostUnpublishAbility–lit/post-unpublish, Input{post_id}; indirekt; idempotent; →draft;changed.action=unpublished.PostScheduleAbility–lit/post-schedule, Input{post_id, date: "JJJJ-MM-TT HH:MM" (Ortszeit der Website)}; indirekt; idempotent; Rechtpublish_post; →future;changed.action=scheduled.PostTrashAbility–lit/post-trash,__construct(ContentSchema $schema, ChangeLog $log, ?callable $trashEnabled = null)(Standard:EMPTY_TRASH_DAYS≠ 0), Input{post_id, confirm}; indirekt;destructive: true, idempotent; Rechtdelete_post;wp_trash_post()nur bei aktivem Papierkorb, sonstlit_unsupported;changed.action=trashed.
-
Step 1: Failing Tests
tests/Unit/Integrations/WordPress/Status/PostPublishAbilityTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status;
use Brain\Monkey\Functions;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Status\PostPublishAbility;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class PostPublishAbilityTest extends TestCase
{
private function ability(ChangeLog $log): PostPublishAbility
{
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->andReturn(true);
return new PostPublishAbility($schema, $log);
}
private function stubPost(array $fields): void
{
Functions\when('get_post')->justReturn(new WP_Post(array_merge(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Team'], $fields)));
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_permalink')->justReturn('https://kunde.de/team/');
Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=5&action=edit');
}
public function testPublishesADraft(): void
{
$this->stubPost(['post_status' => 'draft', 'post_date_gmt' => '0000-00-00 00:00:00']);
Functions\expect('wp_update_post')->once()->with(['ID' => 5, 'post_status' => 'publish'], true)->andReturn(5);
Functions\when('get_post_status')->justReturn('publish');
$log = Mockery::mock(ChangeLog::class);
$log->expects('record')->with('lit/post-publish', 'page', '5', '"Team" veröffentlicht')->andReturn(9);
$result = $this->ability($log)->execute(['post_id' => 5]);
self::assertSame('published', $result['changed'][0]['action']);
self::assertSame(9, $result['log_id']);
self::assertSame('https://kunde.de/team/', $result['preview_url']);
}
public function testScheduledPostIsPublishedNowInsteadOfStayingFuture(): void
{
$this->stubPost(['post_status' => 'future', 'post_date_gmt' => '2099-01-01 10:00:00']);
Functions\when('current_time')->alias(static fn (string $type, $gmt = 0) => $gmt ? '2026-09-25 08:00:00' : '2026-09-25 10:00:00');
Functions\expect('wp_update_post')->once()->with([
'ID' => 5,
'post_status' => 'publish',
'post_date' => '2026-09-25 10:00:00',
'post_date_gmt' => '2026-09-25 08:00:00',
'edit_date' => true,
], true)->andReturn(5);
Functions\when('get_post_status')->justReturn('publish');
$log = Mockery::mock(ChangeLog::class);
$log->allows('record')->andReturn(1);
$this->ability($log)->execute(['post_id' => 5]);
}
public function testAlreadyPublishedIsANoOp(): void
{
$this->stubPost(['post_status' => 'publish']);
Functions\expect('wp_update_post')->never();
$result = $this->ability(Mockery::mock(ChangeLog::class))->execute(['post_id' => 5]);
self::assertSame([], $result['changed']);
self::assertStringContainsString('bereits veröffentlicht', $result['summary']);
}
public function testPermissionNeedsPublishRights(): void
{
$this->stubPost(['post_status' => 'draft']);
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'publish_post');
self::assertSame('lit_forbidden', $this->ability(Mockery::mock(ChangeLog::class))->permission(['post_id' => 5])->get_error_code());
}
}
tests/Unit/Integrations/WordPress/Status/PostUnpublishAbilityTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status;
use Brain\Monkey\Functions;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Status\PostUnpublishAbility;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class PostUnpublishAbilityTest extends TestCase
{
private function ability(ChangeLog $log, string $status): PostUnpublishAbility
{
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Team', 'post_status' => $status]));
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_preview_post_link')->justReturn('https://kunde.de/?p=5&preview=true');
Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=5&action=edit');
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->andReturn(true);
return new PostUnpublishAbility($schema, $log);
}
public function testPublishedPostBecomesDraft(): void
{
Functions\expect('wp_update_post')->once()->with(['ID' => 5, 'post_status' => 'draft'], true)->andReturn(5);
$log = Mockery::mock(ChangeLog::class);
$log->expects('record')->andReturn(3);
$result = $this->ability($log, 'publish')->execute(['post_id' => 5]);
self::assertSame('unpublished', $result['changed'][0]['action']);
}
public function testDraftIsANoOp(): void
{
Functions\expect('wp_update_post')->never();
$result = $this->ability(Mockery::mock(ChangeLog::class), 'draft')->execute(['post_id' => 5]);
self::assertSame([], $result['changed']);
}
}
tests/Unit/Integrations/WordPress/Status/PostScheduleAbilityTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status;
use Brain\Monkey\Functions;
use DateTimeZone;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Status\PostScheduleAbility;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class PostScheduleAbilityTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'post', 'post_title' => 'News', 'post_status' => 'draft']));
Functions\when('current_user_can')->justReturn(true);
Functions\when('wp_timezone')->justReturn(new DateTimeZone('Europe/Berlin'));
Functions\when('get_preview_post_link')->justReturn('https://kunde.de/?p=5&preview=true');
Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=5&action=edit');
}
private function ability(ChangeLog $log): PostScheduleAbility
{
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->andReturn(true);
return new PostScheduleAbility($schema, $log);
}
public function testSchedulesWithLocalAndGmtDates(): void
{
Functions\expect('wp_update_post')->once()->with([
'ID' => 5,
'post_status' => 'future',
'post_date' => '2099-06-01 09:30:00',
'post_date_gmt' => '2099-06-01 07:30:00',
'edit_date' => true,
], true)->andReturn(5);
Functions\when('get_post_status')->justReturn('future');
$log = Mockery::mock(ChangeLog::class);
$log->expects('record')->andReturn(4);
$result = $this->ability($log)->execute(['post_id' => 5, 'date' => '2099-06-01 09:30']);
self::assertSame('scheduled', $result['changed'][0]['action']);
self::assertStringContainsString('2099-06-01 09:30', $result['summary']);
}
public function testPastAndImpossibleDatesAreRejected(): void
{
Functions\expect('wp_update_post')->never();
$ability = $this->ability(Mockery::mock(ChangeLog::class));
$past = $ability->execute(['post_id' => 5, 'date' => '2020-01-01 10:00']);
$impossible = $ability->execute(['post_id' => 5, 'date' => '2099-13-45 10:00']);
self::assertSame('lit_validation_failed', $past->get_error_code());
self::assertStringContainsString('Zukunft', $past->get_error_message());
self::assertSame('lit_validation_failed', $impossible->get_error_code());
}
}
tests/Unit/Integrations/WordPress/Status/PostTrashAbilityTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status;
use Brain\Monkey\Functions;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Status\PostTrashAbility;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class PostTrashAbilityTest extends TestCase
{
private function ability(ChangeLog $log): PostTrashAbility
{
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Alt', 'post_status' => 'publish']));
Functions\when('current_user_can')->justReturn(true);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->andReturn(true);
return new PostTrashAbility($schema, $log);
}
public function testRequiresConfirmation(): void
{
Functions\expect('wp_trash_post')->never();
$result = $this->ability(Mockery::mock(ChangeLog::class))->execute(['post_id' => 5]);
self::assertSame('lit_confirm_required', $result->get_error_code());
}
public function testMovesToTrashNeverDeletes(): void
{
Functions\expect('wp_trash_post')->once()->with(5)->andReturn(new WP_Post(['ID' => 5]));
Functions\expect('wp_delete_post')->never();
$log = Mockery::mock(ChangeLog::class);
$log->expects('record')->with('lit/post-trash', 'page', '5', '"Alt" in den Papierkorb gelegt')->andReturn(2);
$result = $this->ability($log)->execute(['post_id' => 5, 'confirm' => true]);
self::assertSame('trashed', $result['changed'][0]['action']);
self::assertStringContainsString('Papierkorb', $result['summary']);
}
public function testRefusesWhenTheTrashIsDisabledInsteadOfDeleting(): void
{
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_status' => 'publish']));
Functions\when('current_user_can')->justReturn(true);
Functions\expect('wp_trash_post')->never();
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->andReturn(true);
$ability = new PostTrashAbility($schema, Mockery::mock(ChangeLog::class), static fn (): bool => false);
$result = $ability->execute(['post_id' => 5, 'confirm' => true]);
self::assertSame('lit_unsupported', $result->get_error_code());
}
public function testIsDestructiveAndIndirect(): void
{
$ability = $this->ability(Mockery::mock(ChangeLog::class));
self::assertTrue($ability->annotations()['destructive']);
self::assertFalse($ability->isDirect());
}
}
In WordPressIntegrationTest die Konstante ersetzen:
private const EXPECTED = [
'lit/content-types',
'lit/content-query',
'lit/content-get',
'lit/content-create',
'lit/content-update',
'lit/post-publish',
'lit/post-unpublish',
'lit/post-schedule',
'lit/post-trash',
];
- Step 2: Run – muss fehlschlagen
Run: composer test -- --filter 'Status|WordPressIntegrationTest'
Expected: FAIL.
- Step 3: Implementierung
src/Integrations/WordPress/Status/PostPublishAbility.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Status;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
final class PostPublishAbility implements Ability
{
public function __construct(private ContentSchema $schema, private ChangeLog $log)
{
}
public function name(): string
{
return 'lit/post-publish';
}
public function label(): string
{
return 'Veröffentlichen';
}
public function description(): string
{
return 'Veröffentlicht einen Eintrag sofort; geplante oder zukunftsdatierte Einträge erhalten das '
. 'aktuelle Datum. Nur auf ausdrücklichen Wunsch des Nutzers verwenden.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => ['post_id' => ['type' => 'integer', 'minimum' => 1]],
'required' => ['post_id'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema();
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return true;
}
public function permission(array $input): bool|WP_Error
{
$post = $this->load($input);
return $post instanceof WP_Error ? $post : true;
}
public function execute(array $input): mixed
{
$post = $this->load($input);
if ($post instanceof WP_Error) {
return $post;
}
$postId = (int) $post->ID;
$title = (string) $post->post_title;
$editUrl = (string) get_edit_post_link($postId, 'raw');
if ($post->post_status === 'publish') {
return ToolResult::ok(sprintf('"%s" ist bereits veröffentlicht.', $title))
->urls((string) get_permalink($postId), $editUrl)
->toArray();
}
$data = ['ID' => $postId, 'post_status' => 'publish'];
if ($post->post_status === 'future' || $this->isFutureDated($post)) {
$data['post_date'] = current_time('mysql');
$data['post_date_gmt'] = current_time('mysql', true);
$data['edit_date'] = true;
}
$result = wp_update_post($data, true);
if ($result instanceof WP_Error) {
return Errors::invalidInput('WordPress hat das Veröffentlichen abgelehnt: ' . $result->get_error_message());
}
$status = get_post_status($postId);
if ($status !== 'publish') {
return Errors::unsupported(
sprintf('Nach dem Veröffentlichen ist der Status "%s".', (string) $status),
'Eintrag in WordPress prüfen (z. B. Freigabe-Workflow eines anderen Plugins).'
);
}
do_action('lit_mcp_after_save', $postId, $this->name(), $input);
$summary = sprintf('"%s" veröffentlicht', $title);
$logId = $this->log->record($this->name(), (string) $post->post_type, (string) $postId, $summary);
return ToolResult::ok(sprintf('"%s" ist jetzt veröffentlicht.', $title))
->changed((string) $postId, (string) $post->post_type, 'published', $title)
->logId($logId)
->urls((string) get_permalink($postId), $editUrl)
->toArray();
}
/** @param array<string, mixed> $input */
private function load(array $input): WP_Post|WP_Error
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
if ($post instanceof WP_Error) {
return $post;
}
return current_user_can('publish_post', (int) $post->ID)
? $post
: Errors::forbidden('Keine Berechtigung, diesen Eintrag zu veröffentlichen.');
}
private function isFutureDated(WP_Post $post): bool
{
$gmt = (string) $post->post_date_gmt;
return $gmt !== '' && $gmt !== '0000-00-00 00:00:00' && strtotime($gmt . ' UTC') > time();
}
}
src/Integrations/WordPress/Status/PostUnpublishAbility.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Status;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
final class PostUnpublishAbility implements Ability
{
private const NOT_PUBLIC = ['draft', 'pending', 'auto-draft'];
public function __construct(private ContentSchema $schema, private ChangeLog $log)
{
}
public function name(): string
{
return 'lit/post-unpublish';
}
public function label(): string
{
return 'Zurückziehen';
}
public function description(): string
{
return 'Setzt einen veröffentlichten, geplanten oder privaten Eintrag zurück auf Entwurf. '
. 'Nur auf ausdrücklichen Wunsch des Nutzers verwenden.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => ['post_id' => ['type' => 'integer', 'minimum' => 1]],
'required' => ['post_id'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema();
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
return $post instanceof WP_Error ? $post : true;
}
public function execute(array $input): mixed
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
if ($post instanceof WP_Error) {
return $post;
}
$postId = (int) $post->ID;
$title = (string) $post->post_title;
$editUrl = (string) get_edit_post_link($postId, 'raw');
if (in_array($post->post_status, self::NOT_PUBLIC, true)) {
return ToolResult::ok(sprintf('"%s" ist nicht veröffentlicht (Status: %s).', $title, $post->post_status))
->urls((string) get_preview_post_link($post), $editUrl)
->toArray();
}
$result = wp_update_post(['ID' => $postId, 'post_status' => 'draft'], true);
if ($result instanceof WP_Error) {
return Errors::invalidInput('WordPress hat das Zurückziehen abgelehnt: ' . $result->get_error_message());
}
do_action('lit_mcp_after_save', $postId, $this->name(), $input);
$summary = sprintf('"%s" zurückgezogen (vorher %s, jetzt Entwurf)', $title, $post->post_status);
$logId = $this->log->record($this->name(), (string) $post->post_type, (string) $postId, $summary);
return ToolResult::ok($summary . '.')
->changed((string) $postId, (string) $post->post_type, 'unpublished', $title)
->logId($logId)
->urls((string) get_preview_post_link($post), $editUrl)
->toArray();
}
}
src/Integrations/WordPress/Status/PostScheduleAbility.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Status;
use DateTimeImmutable;
use DateTimeZone;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
final class PostScheduleAbility implements Ability
{
private const FORMAT = 'Y-m-d H:i';
public function __construct(private ContentSchema $schema, private ChangeLog $log)
{
}
public function name(): string
{
return 'lit/post-schedule';
}
public function label(): string
{
return 'Veröffentlichung planen';
}
public function description(): string
{
return 'Plant die Veröffentlichung eines Eintrags zu einem Zeitpunkt in der Zukunft (Ortszeit der '
. 'Website). Ein bereits veröffentlichter Eintrag ist bis dahin offline. Nur auf ausdrücklichen '
. 'Wunsch des Nutzers verwenden.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'post_id' => ['type' => 'integer', 'minimum' => 1],
'date' => [
'type' => 'string',
'pattern' => '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$',
'description' => 'JJJJ-MM-TT HH:MM in der Zeitzone der Website.',
],
],
'required' => ['post_id', 'date'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema();
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
$post = $this->load($input);
return $post instanceof WP_Error ? $post : true;
}
public function execute(array $input): mixed
{
$post = $this->load($input);
if ($post instanceof WP_Error) {
return $post;
}
$raw = (string) ($input['date'] ?? '');
$date = DateTimeImmutable::createFromFormat('!' . self::FORMAT, $raw, wp_timezone());
if ($date === false || $date->format(self::FORMAT) !== $raw) {
return Errors::invalidInput('Ungültiges Datum.', ['date' => 'Format JJJJ-MM-TT HH:MM erwartet']);
}
if ($date->getTimestamp() <= time()) {
return Errors::invalidInput(
'Der Zeitpunkt muss in der Zukunft liegen.',
['date' => 'liegt nicht in der Zukunft'],
'Zum sofortigen Veröffentlichen lit-post-publish verwenden.'
);
}
$postId = (int) $post->ID;
$result = wp_update_post([
'ID' => $postId,
'post_status' => 'future',
'post_date' => $date->format('Y-m-d H:i:s'),
'post_date_gmt' => $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s'),
'edit_date' => true,
], true);
if ($result instanceof WP_Error) {
return Errors::invalidInput('WordPress hat das Planen abgelehnt: ' . $result->get_error_message());
}
if (get_post_status($postId) !== 'future') {
return Errors::unsupported(
'WordPress hat den Eintrag nicht als geplant gespeichert.',
'Eintrag in WordPress prüfen (z. B. Freigabe-Workflow eines anderen Plugins).'
);
}
do_action('lit_mcp_after_save', $postId, $this->name(), $input);
$title = (string) $post->post_title;
$summary = sprintf('"%s" geplant für %s', $title, $raw)
. ($post->post_status === 'publish' ? ' (war veröffentlicht und ist bis dahin offline)' : '');
$logId = $this->log->record($this->name(), (string) $post->post_type, (string) $postId, $summary);
return ToolResult::ok($summary . '.')
->changed((string) $postId, (string) $post->post_type, 'scheduled', $title)
->logId($logId)
->urls((string) get_preview_post_link($post), (string) get_edit_post_link($postId, 'raw'))
->toArray();
}
/** @param array<string, mixed> $input */
private function load(array $input): WP_Post|WP_Error
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
if ($post instanceof WP_Error) {
return $post;
}
return current_user_can('publish_post', (int) $post->ID)
? $post
: Errors::forbidden('Keine Berechtigung, diesen Eintrag zu veröffentlichen oder zu planen.');
}
}
src/Integrations/WordPress/Status/PostTrashAbility.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Status;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
final class PostTrashAbility implements Ability
{
/** @var callable(): bool */
private $trashEnabled;
public function __construct(
private ContentSchema $schema,
private ChangeLog $log,
?callable $trashEnabled = null
) {
$this->trashEnabled = $trashEnabled
?? static fn (): bool => ! defined('EMPTY_TRASH_DAYS') || (bool) EMPTY_TRASH_DAYS;
}
public function name(): string
{
return 'lit/post-trash';
}
public function label(): string
{
return 'In den Papierkorb';
}
public function description(): string
{
return 'Legt einen Eintrag in den Papierkorb (nie endgültig löschen; wiederherstellbar in WordPress). '
. 'Erst nach Rückfrage beim Nutzer mit confirm: true aufrufen.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'post_id' => ['type' => 'integer', 'minimum' => 1],
'confirm' => ['type' => 'boolean', 'description' => 'true nach Bestätigung durch den Nutzer.'],
],
'required' => ['post_id'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema();
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => true, 'idempotent' => true];
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
$post = $this->load($input);
return $post instanceof WP_Error ? $post : true;
}
public function execute(array $input): mixed
{
$post = $this->load($input);
if ($post instanceof WP_Error) {
return $post;
}
$postId = (int) $post->ID;
$title = (string) $post->post_title;
if (($input['confirm'] ?? false) !== true) {
return Errors::confirmRequired(sprintf('"%s" (ID %d) in den Papierkorb legen?', $title, $postId));
}
if ($post->post_status === 'trash') {
return ToolResult::ok(sprintf('"%s" liegt bereits im Papierkorb.', $title))->toArray();
}
if (! ($this->trashEnabled)()) {
return Errors::unsupported(
'Der Papierkorb ist auf dieser Website deaktiviert (EMPTY_TRASH_DAYS = 0); '
. 'WordPress würde endgültig löschen. Nichts wurde geändert.',
'Endgültiges Löschen ist über LIT MCP nicht möglich; der Nutzer kann es in WordPress selbst tun.'
);
}
if (! wp_trash_post($postId)) {
return Errors::unsupported(
'WordPress hat das Verschieben in den Papierkorb abgelehnt.',
'Eintrag in WordPress prüfen (ein anderes Plugin kann das Verschieben verhindern).'
);
}
$summary = sprintf('"%s" in den Papierkorb gelegt', $title);
$logId = $this->log->record($this->name(), (string) $post->post_type, (string) $postId, $summary);
return ToolResult::ok(sprintf('"%s" liegt im Papierkorb und ist dort wiederherstellbar.', $title))
->changed((string) $postId, (string) $post->post_type, 'trashed', $title)
->logId($logId)
->toArray();
}
/** @param array<string, mixed> $input */
private function load(array $input): WP_Post|WP_Error
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
if ($post instanceof WP_Error) {
return $post;
}
return current_user_can('delete_post', (int) $post->ID)
? $post
: Errors::forbidden('Keine Berechtigung, diesen Eintrag zu löschen.');
}
}
Hinweis: wp_trash_post() löscht bei EMPTY_TRASH_DAYS = 0 endgültig (wp-includes/post.php, function wp_trash_post: if ( ! EMPTY_TRASH_DAYS ) return wp_delete_post( $post_id, true );). Deshalb prüft das Tool vorher über das injizierbare $trashEnabled und lehnt ab, statt zu löschen.
src/Integrations/WordPress/WordPressIntegration.php: Imports ergänzen
use LIT\MCP\Integrations\WordPress\Status\PostPublishAbility;
use LIT\MCP\Integrations\WordPress\Status\PostScheduleAbility;
use LIT\MCP\Integrations\WordPress\Status\PostTrashAbility;
use LIT\MCP\Integrations\WordPress\Status\PostUnpublishAbility;
und in abilities() nach new ContentUpdateAbility(...) einfügen:
new PostPublishAbility($this->schema, $this->log),
new PostUnpublishAbility($this->schema, $this->log),
new PostScheduleAbility($this->schema, $this->log),
new PostTrashAbility($this->schema, $this->log),
- Step 4: Run – muss bestehen
Run: composer test && composer lint
Expected: PASS. Zeilen über 120 Zeichen (z. B. die record(...)-Aufrufe) für phpcs umbrechen.
- Step 5: Commit
git add src/Integrations/WordPress tests/Unit/Integrations/WordPress
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: explicit publish, unpublish, schedule and trash tools
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
Task 11: Kopie und Austausch – PostCloner, lit/post-duplicate, lit/post-replace-with-copy
Spec §6.2: Entwurfskopie inkl. Meta, Begriffe, Divi-Flags; Austausch übernimmt Inhalt/Meta/Begriffe der Kopie ins Original (ID, URL, Menüeinträge bleiben), das Original erhält vorher eine Revision, die Kopie kommt in den Papierkorb.
Files:
- Create:
src/Integrations/WordPress/Status/PostCloner.php,src/Integrations/WordPress/Status/PostDuplicateAbility.php,src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php - Modify:
src/Integrations/WordPress/WordPressIntegration.php,tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php - Test:
tests/Unit/Integrations/WordPress/Status/PostClonerTest.php,tests/Unit/Integrations/WordPress/Status/PostDuplicateAbilityTest.php,tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php
Interfaces:
-
Consumes:
PostGuard,RevisionPoint,ContentSchema,ChangeLog,ToolResult,Errors. -
Produces:
PostCloner(nicht final):COPY_OF_META = '_lit_mcp_copy_of';duplicate(WP_Post $original): int(Entwurf „ (Kopie)“, Autor = aktueller Benutzer, alle Meta außerSKIP_META, alle Begriffe, Markierung_lit_mcp_copy_of; wirftRuntimeException);replace(WP_Post $original, WP_Post $copy): void(Titel ohne „ (Kopie)“, Inhalt, Auszug; Meta des Originals außerSKIP_METAgelöscht und durch die der Kopie ersetzt; Begriffe je Taxonomie ersetzt; Status, Slug, Datum, Autor des Originals bleiben).PostDuplicateAbility:__construct(ContentSchema $schema, PostCloner $cloner, ChangeLog $log);lit/post-duplicate, Input{post_id}; indirekt; nicht idempotent; Ergebnis-Extraoriginal_id.PostReplaceWithCopyAbility:__construct(ContentSchema $schema, PostCloner $cloner, ChangeLog $log, ?callable $trashEnabled = null);lit/post-replace-with-copy, Input{original_id, copy_id, confirm}; indirekt;destructive: true, nicht idempotent; verlangt, dass die Kopie perlit/post-duplicateaus genau diesem Original entstand.
-
Step 1: Failing Tests
tests/Unit/Integrations/WordPress/Status/PostClonerTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\WordPress\Status\PostCloner;
use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException;
use WP_Error;
use WP_Post;
final class PostClonerTest extends TestCase
{
private function original(): WP_Post
{
return new WP_Post([
'ID' => 5,
'post_type' => 'page',
'post_title' => 'Team',
'post_content' => '<!-- wp:divi/section /-->',
'post_excerpt' => 'Kurz',
'post_parent' => 2,
'menu_order' => 3,
]);
}
public function testDuplicateCopiesContentMetaAndTermsAsDraft(): void
{
Functions\when('get_current_user_id')->justReturn(4);
Functions\when('maybe_unserialize')->returnArg();
Functions\expect('wp_insert_post')->once()->with($this->draftMatcher(), true)->andReturn(77);
Functions\when('get_post_meta')->justReturn([
'_edit_lock' => ['123:1'],
'_et_pb_use_builder' => ['on'],
'role' => ['Leitung'],
]);
Functions\expect('add_post_meta')->once()->with(77, '_et_pb_use_builder', 'on');
Functions\expect('add_post_meta')->once()->with(77, 'role', 'Leitung');
Functions\when('get_object_taxonomies')->justReturn(['category']);
Functions\when('wp_get_object_terms')->justReturn([4, 9]);
Functions\expect('wp_set_object_terms')->once()->with(77, [4, 9], 'category', false);
Functions\expect('update_post_meta')->once()->with(77, '_lit_mcp_copy_of', 5);
self::assertSame(77, (new PostCloner())->duplicate($this->original()));
}
public function testDuplicateFailureThrows(): void
{
Functions\when('get_current_user_id')->justReturn(4);
Functions\when('wp_insert_post')->justReturn(new WP_Error('x', 'kaputt'));
$this->expectException(RuntimeException::class);
(new PostCloner())->duplicate($this->original());
}
public function testReplaceOverwritesContentMetaAndTermsButKeepsTheOriginalsIdentity(): void
{
$copy = new WP_Post([
'ID' => 77,
'post_type' => 'page',
'post_title' => 'Team (Kopie)',
'post_content' => 'neu',
'post_excerpt' => 'Neu kurz',
]);
Functions\when('maybe_unserialize')->returnArg();
Functions\expect('wp_update_post')->once()->with([
'ID' => 5,
'post_title' => 'Team',
'post_content' => 'neu',
'post_excerpt' => 'Neu kurz',
], true)->andReturn(5);
Functions\when('get_post_meta')->alias(static fn (int $id) => $id === 5
? ['_edit_lock' => ['1'], 'role' => ['Alt'], 'city' => ['Trier']]
: ['_lit_mcp_copy_of' => ['5'], 'role' => ['Neu']]);
Functions\expect('delete_post_meta')->once()->with(5, 'role');
Functions\expect('delete_post_meta')->once()->with(5, 'city');
Functions\expect('add_post_meta')->once()->with(5, 'role', 'Neu');
Functions\when('get_object_taxonomies')->justReturn(['category']);
Functions\when('wp_get_object_terms')->justReturn([12]);
Functions\expect('wp_set_object_terms')->once()->with(5, [12], 'category', false);
(new PostCloner())->replace($this->original(), $copy);
}
private function draftMatcher(): \Mockery\Matcher\Closure
{
return \Mockery::on(static fn (array $data): bool => $data['post_status'] === 'draft'
&& $data['post_type'] === 'page'
&& $data['post_title'] === 'Team (Kopie)'
&& $data['post_content'] === '<!-- wp:divi/section /-->'
&& $data['post_parent'] === 2
&& $data['menu_order'] === 3
&& $data['post_author'] === 4);
}
}
tests/Unit/Integrations/WordPress/Status/PostDuplicateAbilityTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status;
use Brain\Monkey\Actions;
use Brain\Monkey\Functions;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Status\PostCloner;
use LIT\MCP\Integrations\WordPress\Status\PostDuplicateAbility;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class PostDuplicateAbilityTest extends TestCase
{
public function testCreatesADraftCopyAndReportsIt(): void
{
$original = new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Team']);
Functions\when('get_post')->alias(static fn (int $id) => $id === 5
? $original
: new WP_Post(['ID' => 77, 'post_type' => 'page', 'post_title' => 'Team (Kopie)']));
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['create_posts' => 'edit_pages']]);
Functions\when('get_preview_post_link')->justReturn('https://kunde.de/?p=77&preview=true');
Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=77&action=edit');
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->andReturn(true);
$cloner = Mockery::mock(PostCloner::class);
$cloner->expects('duplicate')->with($original)->andReturn(77);
Actions\expectDone('lit_mcp_after_save')->once()->with(77, 'lit/post-duplicate', ['post_id' => 5]);
$log = Mockery::mock(ChangeLog::class);
$log->expects('record')->with('lit/post-duplicate', 'page', '77', 'Kopie von "Team" (ID 5) angelegt')->andReturn(8);
$result = (new PostDuplicateAbility($schema, $cloner, $log))->execute(['post_id' => 5]);
self::assertSame([['id' => '77', 'type' => 'page', 'action' => 'created', 'label' => 'Team (Kopie)']], $result['changed']);
self::assertSame(5, $result['original_id']);
self::assertSame(8, $result['log_id']);
}
}
tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status;
use Brain\Monkey\Functions;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Status\PostCloner;
use LIT\MCP\Integrations\WordPress\Status\PostReplaceWithCopyAbility;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class PostReplaceWithCopyAbilityTest extends TestCase
{
private WP_Post $original;
private WP_Post $copy;
protected function setUp(): void
{
parent::setUp();
$this->original = new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Team', 'post_status' => 'publish']);
$this->copy = new WP_Post(['ID' => 77, 'post_type' => 'page', 'post_title' => 'Team (Kopie)', 'post_status' => 'draft']);
Functions\when('get_post')->alias(fn (int $id) => $id === 5 ? $this->original : $this->copy);
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_post_meta')->justReturn('5');
Functions\when('get_permalink')->justReturn('https://kunde.de/team/');
Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=5&action=edit');
}
private function ability(PostCloner $cloner, ChangeLog $log, bool $trash = true): PostReplaceWithCopyAbility
{
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->andReturn(true);
return new PostReplaceWithCopyAbility($schema, $cloner, $log, static fn (): bool => $trash);
}
public function testRequiresConfirmation(): void
{
$cloner = Mockery::mock(PostCloner::class);
$cloner->expects('replace')->never();
$result = $this->ability($cloner, Mockery::mock(ChangeLog::class))->execute(['original_id' => 5, 'copy_id' => 77]);
self::assertSame('lit_confirm_required', $result->get_error_code());
}
public function testCopyMustStemFromTheOriginal(): void
{
Functions\when('get_post_meta')->justReturn('12');
$cloner = Mockery::mock(PostCloner::class);
$cloner->expects('replace')->never();
$result = $this->ability($cloner, Mockery::mock(ChangeLog::class))
->execute(['original_id' => 5, 'copy_id' => 77, 'confirm' => true]);
self::assertSame('lit_validation_failed', $result->get_error_code());
}
public function testDisabledTrashIsRefusedBeforeAnyChange(): void
{
$cloner = Mockery::mock(PostCloner::class);
$cloner->expects('replace')->never();
$result = $this->ability($cloner, Mockery::mock(ChangeLog::class), false)
->execute(['original_id' => 5, 'copy_id' => 77, 'confirm' => true]);
self::assertSame('lit_unsupported', $result->get_error_code());
}
public function testRevisionThenReplaceThenTrashTheCopy(): void
{
$order = [];
Functions\expect('wp_save_post_revision')->once()->with(5)->andReturnUsing(static function () use (&$order) {
$order[] = 'revision';
return 900;
});
Functions\when('wp_get_post_revisions')->justReturn([900 => new WP_Post(['ID' => 900])]);
$cloner = Mockery::mock(PostCloner::class);
$cloner->expects('replace')->with($this->original, $this->copy)->andReturnUsing(static function () use (&$order): void {
$order[] = 'replace';
});
Functions\expect('wp_trash_post')->once()->with(77)->andReturnUsing(static function () use (&$order): WP_Post {
$order[] = 'trash';
return new WP_Post(['ID' => 77]);
});
$log = Mockery::mock(ChangeLog::class);
$log->expects('record')->with('lit/post-replace-with-copy', 'page', '5', Mockery::type('string'), 900)->andReturn(11);
$result = $this->ability($cloner, $log)->execute(['original_id' => 5, 'copy_id' => 77, 'confirm' => true]);
self::assertSame(['revision', 'replace', 'trash'], $order);
self::assertSame(900, $result['revision_id']);
self::assertSame(['replaced', 'trashed'], array_column($result['changed'], 'action'));
}
}
In WordPressIntegrationTest die Konstante ersetzen:
private const EXPECTED = [
'lit/content-types',
'lit/content-query',
'lit/content-get',
'lit/content-create',
'lit/content-update',
'lit/post-publish',
'lit/post-unpublish',
'lit/post-schedule',
'lit/post-trash',
'lit/post-duplicate',
'lit/post-replace-with-copy',
];
- Step 2: Run – muss fehlschlagen
Run: composer test -- --filter 'PostCloner|PostDuplicate|PostReplaceWithCopy|WordPressIntegrationTest'
Expected: FAIL.
- Step 3: Implementierung
src/Integrations/WordPress/Status/PostCloner.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Status;
use RuntimeException;
use WP_Error;
use WP_Post;
/**
* Copies entries (draft copy) and swaps a copy's content back into its original.
* Meta is copied raw (incl. Divi flags and Pods meta); Pods relationships stored in
* the meta table travel with it. Not final so abilities can be tested with a mock.
*/
class PostCloner
{
public const COPY_OF_META = '_lit_mcp_copy_of';
/** Bookkeeping meta that belongs to one post and must never be copied or cleared. */
private const SKIP_META = [
'_edit_lock', '_edit_last', '_wp_old_slug', '_wp_old_date', '_wp_trash_meta_status',
'_wp_trash_meta_time', '_wp_desired_post_slug', '_encloseme', '_pingme', self::COPY_OF_META,
];
/** @throws RuntimeException */
public function duplicate(WP_Post $original): int
{
$copyId = wp_insert_post(wp_slash([
'post_type' => (string) $original->post_type,
'post_status' => 'draft',
'post_title' => sprintf('%s (Kopie)', $original->post_title),
'post_content' => (string) $original->post_content,
'post_excerpt' => (string) $original->post_excerpt,
'post_parent' => (int) $original->post_parent,
'menu_order' => (int) $original->menu_order,
'comment_status' => (string) $original->comment_status,
'ping_status' => (string) $original->ping_status,
'post_password' => (string) $original->post_password,
'post_author' => get_current_user_id(),
]), true);
if ($copyId instanceof WP_Error || (int) $copyId === 0) {
$reason = $copyId instanceof WP_Error ? $copyId->get_error_message() : 'unknown';
throw new RuntimeException('Copy could not be created: ' . $reason);
}
$copyId = (int) $copyId;
$this->copyMeta((int) $original->ID, $copyId);
$this->copyTerms($original, $copyId);
update_post_meta($copyId, self::COPY_OF_META, (int) $original->ID);
return $copyId;
}
/** @throws RuntimeException */
public function replace(WP_Post $original, WP_Post $copy): void
{
$result = wp_update_post(wp_slash([
'ID' => (int) $original->ID,
'post_title' => (string) preg_replace('/ \(Kopie\)$/u', '', (string) $copy->post_title),
'post_content' => (string) $copy->post_content,
'post_excerpt' => (string) $copy->post_excerpt,
]), true);
if ($result instanceof WP_Error) {
throw new RuntimeException('Original could not be updated: ' . $result->get_error_message());
}
foreach (array_keys((array) get_post_meta((int) $original->ID)) as $key) {
if (! in_array((string) $key, self::SKIP_META, true)) {
delete_post_meta((int) $original->ID, (string) $key);
}
}
$this->copyMeta((int) $copy->ID, (int) $original->ID);
$this->copyTerms($copy, (int) $original->ID);
}
private function copyMeta(int $from, int $to): void
{
foreach ((array) get_post_meta($from) as $key => $values) {
if (in_array((string) $key, self::SKIP_META, true)) {
continue;
}
foreach ((array) $values as $value) {
add_post_meta($to, (string) $key, wp_slash(maybe_unserialize($value)));
}
}
}
private function copyTerms(WP_Post $from, int $to): void
{
foreach (get_object_taxonomies((string) $from->post_type) as $taxonomy) {
$ids = wp_get_object_terms((int) $from->ID, $taxonomy, ['fields' => 'ids']);
if (is_array($ids)) {
wp_set_object_terms($to, array_map('intval', $ids), $taxonomy, false);
}
}
}
}
src/Integrations/WordPress/Status/PostDuplicateAbility.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Status;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
final class PostDuplicateAbility implements Ability
{
public function __construct(
private ContentSchema $schema,
private PostCloner $cloner,
private ChangeLog $log
) {
}
public function name(): string
{
return 'lit/post-duplicate';
}
public function label(): string
{
return 'Entwurfskopie anlegen';
}
public function description(): string
{
return 'Legt eine Entwurfskopie eines Eintrags an (Inhalt, Felder/Meta, Begriffe, Divi-Einstellungen). '
. 'Typischer Ablauf: Kopie bearbeiten, prüfen, dann mit lit-post-replace-with-copy ins Original '
. 'übernehmen – so bleibt die veröffentlichte Seite bis dahin unverändert.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => ['post_id' => ['type' => 'integer', 'minimum' => 1]],
'required' => ['post_id'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema(['original_id' => ['type' => 'integer']], ['original_id']);
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => false, 'idempotent' => false];
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
$post = $this->load($input);
return $post instanceof WP_Error ? $post : true;
}
public function execute(array $input): mixed
{
$original = $this->load($input);
if ($original instanceof WP_Error) {
return $original;
}
$copyId = $this->cloner->duplicate($original);
do_action('lit_mcp_after_save', $copyId, $this->name(), $input);
$copy = get_post($copyId);
$copyTitle = $copy instanceof WP_Post ? (string) $copy->post_title : '';
$postType = (string) $original->post_type;
$summary = sprintf('Kopie von "%s" (ID %d) angelegt', $original->post_title, $original->ID);
$logId = $this->log->record($this->name(), $postType, (string) $copyId, $summary);
return ToolResult::ok(sprintf('%s: Entwurf ID %d.', $summary, $copyId))
->changed((string) $copyId, $postType, 'created', $copyTitle)
->logId($logId)
->urls((string) get_preview_post_link($copyId), (string) get_edit_post_link($copyId, 'raw'))
->with('original_id', (int) $original->ID)
->toArray();
}
/** @param array<string, mixed> $input */
private function load(array $input): WP_Post|WP_Error
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
if ($post instanceof WP_Error) {
return $post;
}
$type = PostGuard::postType((string) $post->post_type, $this->schema, 'create_posts');
return $type instanceof WP_Error ? $type : $post;
}
}
src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Status;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\RevisionPoint;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
final class PostReplaceWithCopyAbility implements Ability
{
/** @var callable(): bool */
private $trashEnabled;
public function __construct(
private ContentSchema $schema,
private PostCloner $cloner,
private ChangeLog $log,
?callable $trashEnabled = null
) {
$this->trashEnabled = $trashEnabled
?? static fn (): bool => ! defined('EMPTY_TRASH_DAYS') || (bool) EMPTY_TRASH_DAYS;
}
public function name(): string
{
return 'lit/post-replace-with-copy';
}
public function label(): string
{
return 'Kopie ins Original übernehmen';
}
public function description(): string
{
return 'Übernimmt Titel, Inhalt, Felder/Meta und Begriffe einer mit lit-post-duplicate erstellten Kopie '
. 'ins Original. ID, URL, Status und Menüeinträge des Originals bleiben; vorher entsteht eine '
. 'Revision des Originals, die Kopie kommt danach in den Papierkorb. Erst nach Rückfrage mit '
. 'confirm: true aufrufen.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'original_id' => ['type' => 'integer', 'minimum' => 1],
'copy_id' => ['type' => 'integer', 'minimum' => 1],
'confirm' => ['type' => 'boolean'],
],
'required' => ['original_id', 'copy_id'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema();
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => true, 'idempotent' => false];
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
$pair = $this->load($input);
return $pair instanceof WP_Error ? $pair : true;
}
public function execute(array $input): mixed
{
$pair = $this->load($input);
if ($pair instanceof WP_Error) {
return $pair;
}
[$original, $copy] = $pair;
$originalId = (int) $original->ID;
$copyId = (int) $copy->ID;
if (($input['confirm'] ?? false) !== true) {
return Errors::confirmRequired(sprintf(
'Inhalt von "%s" (ID %d) durch die Kopie ID %d ersetzen und die Kopie in den Papierkorb legen?',
$original->post_title,
$originalId,
$copyId
));
}
$copyOf = (int) get_post_meta($copyId, PostCloner::COPY_OF_META, true);
if ($copy->post_type !== $original->post_type || $copyOf !== $originalId) {
return Errors::invalidInput(
'Die Kopie gehört nicht zu diesem Original.',
['copy_id' => sprintf('keine mit lit-post-duplicate erstellte Kopie von %d', $originalId)],
'Kopie mit lit-post-duplicate aus diesem Original erstellen.'
);
}
if (! ($this->trashEnabled)()) {
return Errors::unsupported(
'Der Papierkorb ist deaktiviert (EMPTY_TRASH_DAYS = 0); die Kopie würde endgültig gelöscht. '
. 'Nichts wurde geändert.',
'Den Austausch in WordPress manuell durchführen.'
);
}
$revisionId = RevisionPoint::capture($originalId);
$this->cloner->replace($original, $copy);
wp_trash_post($copyId);
do_action('lit_mcp_after_save', $originalId, $this->name(), $input);
$title = (string) preg_replace('/ \(Kopie\)$/u', '', (string) $copy->post_title);
$postType = (string) $original->post_type;
$summary = sprintf('Kopie ID %d in "%s" (ID %d) übernommen', $copyId, $title, $originalId);
$logId = $this->log->record($this->name(), $postType, (string) $originalId, $summary, $revisionId);
$statusNote = sprintf('; Status bleibt %s, Kopie liegt im Papierkorb.', $original->post_status);
return ToolResult::ok($summary . $statusNote)
->changed((string) $originalId, $postType, 'replaced', $title)
->changed((string) $copyId, $postType, 'trashed', (string) $copy->post_title)
->revisionId($revisionId)
->logId($logId)
->urls((string) get_permalink($originalId), (string) get_edit_post_link($originalId, 'raw'))
->toArray();
}
/**
* @param array<string, mixed> $input
* @return array{0: WP_Post, 1: WP_Post}|WP_Error
*/
private function load(array $input): array|WP_Error
{
$original = PostGuard::editable($input['original_id'] ?? null, $this->schema, 'original_id');
if ($original instanceof WP_Error) {
return $original;
}
$copy = PostGuard::editable($input['copy_id'] ?? null, $this->schema, 'copy_id');
if ($copy instanceof WP_Error) {
return $copy;
}
if (! current_user_can('delete_post', (int) $copy->ID)) {
return Errors::forbidden('Keine Berechtigung, die Kopie in den Papierkorb zu legen.');
}
return [$original, $copy];
}
}
src/Integrations/WordPress/WordPressIntegration.php: Imports ergänzen
use LIT\MCP\Integrations\WordPress\Status\PostCloner;
use LIT\MCP\Integrations\WordPress\Status\PostDuplicateAbility;
use LIT\MCP\Integrations\WordPress\Status\PostReplaceWithCopyAbility;
und in abilities() vor dem return $cloner = new PostCloner(); ergänzen sowie nach new PostTrashAbility(...) einfügen:
new PostDuplicateAbility($this->schema, $cloner, $this->log),
new PostReplaceWithCopyAbility($this->schema, $cloner, $this->log),
- Step 4: Run – muss bestehen
Run: composer test && composer lint
Expected: PASS (lange Zeilen für phpcs umbrechen).
- Step 5: Commit
git add src/Integrations/WordPress tests/Unit/Integrations/WordPress
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: draft copies and replace-with-copy
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
Task 12: Revisionen – lit/revisions-list, lit/revision-restore
Files:
- Create:
src/Integrations/WordPress/Revisions/RevisionsListAbility.php,src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php - Modify:
src/Integrations/WordPress/WordPressIntegration.php,tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php - Test:
tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php,tests/Unit/Integrations/WordPress/Revisions/RevisionRestoreAbilityTest.php
Interfaces:
-
Consumes:
PostGuard,RevisionPoint,Cursor,Rev,ChangeLog,ToolResult,Errors. -
Produces:
RevisionsListAbility:__construct(ContentSchema $schema);lit/revisions-list, Input{post_id, limit?, cursor?}, Output{post_id, items: list<{id, date, author{id,name}, title, autosave, excerpt}>, next_cursor}; indirekt; readonly.RevisionRestoreAbility:__construct(ContentSchema $schema, ChangeLog $log);lit/revision-restore, Input{revision_id, confirm}; indirekt;destructive: true, idempotent; sichert den aktuellen Stand vorher als Revision (revision_idim Ergebnis = dieser Wiederherstellungspunkt); Status unverändert.
-
Step 1: Failing Tests
tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Revisions;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Cursor;
use LIT\MCP\Integrations\WordPress\Revisions\RevisionsListAbility;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class RevisionsListAbilityTest extends TestCase
{
public function testListsRevisionsNewestFirstWithCursor(): void
{
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'page']));
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_the_author_meta')->justReturn('Jan');
Functions\when('wp_is_post_autosave')->alias(static fn ($post): bool => $post->ID === 811);
Functions\when('wp_strip_all_tags')->alias(static fn (string $text): string => strip_tags($text));
Functions\expect('wp_get_post_revisions')->once()->with(5, ['posts_per_page' => 3, 'offset' => 0])->andReturn([
812 => new WP_Post(['ID' => 812, 'post_title' => 'Team', 'post_content' => '<p>Neu</p>', 'post_author' => '2']),
811 => new WP_Post(['ID' => 811, 'post_title' => 'Team', 'post_content' => '<p>Auto</p>', 'post_author' => '2']),
810 => new WP_Post(['ID' => 810, 'post_title' => 'Team', 'post_content' => '<p>Alt</p>', 'post_author' => '2']),
]);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->andReturn(true);
$result = (new RevisionsListAbility($schema))->execute(['post_id' => 5, 'limit' => 2]);
self::assertSame(5, $result['post_id']);
self::assertSame([812, 811], array_column($result['items'], 'id'));
self::assertSame('Neu', $result['items'][0]['excerpt']);
self::assertTrue($result['items'][1]['autosave']);
self::assertSame(['id' => 2, 'name' => 'Jan'], $result['items'][0]['author']);
self::assertSame(2, Cursor::decode($result['next_cursor']));
}
}
tests/Unit/Integrations/WordPress/Revisions/RevisionRestoreAbilityTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Revisions;
use Brain\Monkey\Functions;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\Revisions\RevisionRestoreAbility;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class RevisionRestoreAbilityTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Functions\when('get_post')->alias(static fn (int $id) => match ($id) {
810 => new WP_Post(['ID' => 810, 'post_type' => 'revision', 'post_parent' => 5, 'post_date' => '2026-09-20 10:00:00']),
5 => new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Team', 'post_status' => 'publish', 'post_content' => 'alt']),
default => null,
});
Functions\when('current_user_can')->justReturn(true);
}
private function ability(ChangeLog $log): RevisionRestoreAbility
{
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->andReturn(true);
return new RevisionRestoreAbility($schema, $log);
}
public function testNonRevisionIdIsNotFound(): void
{
self::assertSame('lit_not_found', $this->ability(Mockery::mock(ChangeLog::class))->permission(['revision_id' => 5])->get_error_code());
}
public function testRequiresConfirmation(): void
{
Functions\expect('wp_restore_post_revision')->never();
$result = $this->ability(Mockery::mock(ChangeLog::class))->execute(['revision_id' => 810]);
self::assertSame('lit_confirm_required', $result->get_error_code());
}
public function testSavesTheCurrentStateThenRestores(): void
{
$order = [];
Functions\expect('wp_save_post_revision')->once()->with(5)->andReturnUsing(static function () use (&$order) {
$order[] = 'backup';
return 813;
});
Functions\when('wp_get_post_revisions')->justReturn([813 => new WP_Post(['ID' => 813])]);
Functions\expect('wp_restore_post_revision')->once()->with(810)->andReturnUsing(static function () use (&$order): int {
$order[] = 'restore';
return 5;
});
Functions\when('get_preview_post_link')->justReturn('https://kunde.de/team/');
Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=5&action=edit');
$log = Mockery::mock(ChangeLog::class);
$log->expects('record')->with('lit/revision-restore', 'page', '5', Mockery::type('string'), 813)->andReturn(12);
$result = $this->ability($log)->execute(['revision_id' => 810, 'confirm' => true]);
self::assertSame(['backup', 'restore'], $order);
self::assertSame(813, $result['revision_id']);
self::assertSame('restored', $result['changed'][0]['action']);
self::assertStringContainsString('2026-09-20 10:00:00', $result['summary']);
}
}
In WordPressIntegrationTest die Konstante um 'lit/revisions-list', 'lit/revision-restore' (nach 'lit/post-replace-with-copy') ergänzen:
private const EXPECTED = [
'lit/content-types',
'lit/content-query',
'lit/content-get',
'lit/content-create',
'lit/content-update',
'lit/post-publish',
'lit/post-unpublish',
'lit/post-schedule',
'lit/post-trash',
'lit/post-duplicate',
'lit/post-replace-with-copy',
'lit/revisions-list',
'lit/revision-restore',
];
- Step 2: Run – muss fehlschlagen
Run: composer test -- --filter 'Revision|WordPressIntegrationTest'
Expected: FAIL.
- Step 3: Implementierung
src/Integrations/WordPress/Revisions/RevisionsListAbility.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Revisions;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Cursor;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
final class RevisionsListAbility implements Ability
{
public function __construct(private ContentSchema $schema)
{
}
public function name(): string
{
return 'lit/revisions-list';
}
public function label(): string
{
return 'Revisionen auflisten';
}
public function description(): string
{
return 'Listet die gespeicherten Revisionen eines Eintrags (neueste zuerst) mit Datum, Autor und '
. 'Textauszug. Wiederherstellen mit lit-revision-restore.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => array_merge(
['post_id' => ['type' => 'integer', 'minimum' => 1]],
Cursor::inputProperties()
),
'required' => ['post_id'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return [
'type' => 'object',
'properties' => [
'post_id' => ['type' => 'integer'],
'items' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer'],
'date' => ['type' => 'string'],
'author' => ['type' => 'object'],
'title' => ['type' => 'string'],
'autosave' => ['type' => 'boolean'],
'excerpt' => ['type' => 'string'],
],
'required' => ['id', 'date', 'author', 'title', 'autosave', 'excerpt'],
],
],
'next_cursor' => ['type' => ['string', 'null']],
],
'required' => ['post_id', 'items', 'next_cursor'],
];
}
public function annotations(): array
{
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
return $post instanceof WP_Error ? $post : true;
}
public function execute(array $input): mixed
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
if ($post instanceof WP_Error) {
return $post;
}
$offset = Cursor::decode(isset($input['cursor']) ? (string) $input['cursor'] : null);
if ($offset === null) {
return Cursor::invalid();
}
$limit = Cursor::limit($input['limit'] ?? null);
$revisions = wp_get_post_revisions((int) $post->ID, ['posts_per_page' => $limit + 1, 'offset' => $offset]);
$page = Cursor::page(array_values(is_array($revisions) ? $revisions : []), $limit, $offset);
return [
'post_id' => (int) $post->ID,
'items' => array_map(fn (WP_Post $revision): array => $this->item($revision), $page['items']),
'next_cursor' => $page['next_cursor'],
];
}
/** @return array<string, mixed> */
private function item(WP_Post $revision): array
{
$text = trim((string) preg_replace('/\s+/u', ' ', wp_strip_all_tags((string) $revision->post_content)));
return [
'id' => (int) $revision->ID,
'date' => (string) $revision->post_date,
'author' => [
'id' => (int) $revision->post_author,
'name' => (string) get_the_author_meta('display_name', (int) $revision->post_author),
],
'title' => (string) $revision->post_title,
'autosave' => (bool) wp_is_post_autosave($revision),
'excerpt' => mb_substr($text, 0, 160),
];
}
}
src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Revisions;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\RevisionPoint;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
final class RevisionRestoreAbility implements Ability
{
public function __construct(private ContentSchema $schema, private ChangeLog $log)
{
}
public function name(): string
{
return 'lit/revision-restore';
}
public function label(): string
{
return 'Revision wiederherstellen';
}
public function description(): string
{
return 'Stellt Titel, Inhalt und Auszug eines Eintrags aus einer Revision wieder her (Status bleibt). '
. 'Der aktuelle Stand wird vorher als Revision gesichert (revision_id im Ergebnis). Erst nach '
. 'Rückfrage mit confirm: true aufrufen.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'revision_id' => ['type' => 'integer', 'minimum' => 1],
'confirm' => ['type' => 'boolean'],
],
'required' => ['revision_id'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema();
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => true, 'idempotent' => true];
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
$pair = $this->load($input);
return $pair instanceof WP_Error ? $pair : true;
}
public function execute(array $input): mixed
{
$pair = $this->load($input);
if ($pair instanceof WP_Error) {
return $pair;
}
[$revision, $post] = $pair;
$postId = (int) $post->ID;
if (($input['confirm'] ?? false) !== true) {
return Errors::confirmRequired(sprintf(
'"%s" (ID %d) auf den Stand vom %s zurücksetzen?',
$post->post_title,
$postId,
$revision->post_date
));
}
$backupId = RevisionPoint::capture($postId);
$restored = wp_restore_post_revision((int) $revision->ID);
if (! $restored) {
return Errors::unsupported(
'WordPress konnte die Revision nicht wiederherstellen.',
'Revision in WordPress unter „Revisionen“ prüfen.'
);
}
do_action('lit_mcp_after_save', $postId, $this->name(), $input);
$updated = get_post($postId);
$updated = $updated instanceof WP_Post ? $updated : $post;
$summary = sprintf('"%s" auf Stand vom %s zurückgesetzt', $updated->post_title, $revision->post_date);
$postType = (string) $post->post_type;
$logId = $this->log->record($this->name(), $postType, (string) $postId, $summary, $backupId);
return ToolResult::ok($summary . sprintf(' (Status unverändert: %s).', $updated->post_status))
->changed((string) $postId, $postType, 'restored', (string) $updated->post_title)
->rev(Rev::of((string) $updated->post_content))
->revisionId($backupId)
->logId($logId)
->urls((string) get_preview_post_link($updated), (string) get_edit_post_link($postId, 'raw'))
->toArray();
}
/**
* @param array<string, mixed> $input
* @return array{0: WP_Post, 1: WP_Post}|WP_Error
*/
private function load(array $input): array|WP_Error
{
$revisionId = PostGuard::id($input['revision_id'] ?? null);
$revision = $revisionId === null ? null : get_post($revisionId);
if (! $revision instanceof WP_Post || $revision->post_type !== 'revision') {
return Errors::notFound('Revision nicht gefunden.', 'Revisionen über lit-revisions-list abrufen.');
}
$post = PostGuard::editable((int) $revision->post_parent, $this->schema);
return $post instanceof WP_Error ? $post : [$revision, $post];
}
}
src/Integrations/WordPress/WordPressIntegration.php: Imports ergänzen
use LIT\MCP\Integrations\WordPress\Revisions\RevisionRestoreAbility;
use LIT\MCP\Integrations\WordPress\Revisions\RevisionsListAbility;
und in abilities() nach new PostReplaceWithCopyAbility(...) einfügen:
new RevisionsListAbility($this->schema),
new RevisionRestoreAbility($this->schema, $this->log),
- Step 4: Run – muss bestehen
Run: composer test && composer lint
Expected: PASS.
- Step 5: Commit
git add src/Integrations/WordPress tests/Unit/Integrations/WordPress
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: list and restore revisions
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
Task 13: Menüs lesen, anlegen, Positionen – lit/menu-list, lit/menu-get, lit/menu-create, lit/menu-assign-location
Recht für alle Menü-Tools: edit_theme_options (Spec §4.5).
Files:
- Create:
src/Integrations/WordPress/Menus/MenuTree.php,src/Integrations/WordPress/Menus/MenuLocations.php,src/Integrations/WordPress/Menus/MenuListAbility.php,src/Integrations/WordPress/Menus/MenuGetAbility.php,src/Integrations/WordPress/Menus/MenuCreateAbility.php,src/Integrations/WordPress/Menus/MenuAssignLocationAbility.php - Modify:
src/Integrations/WordPress/WordPressIntegration.php,tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php - Test:
tests/Unit/Integrations/WordPress/Menus/MenuTreeTest.php,tests/Unit/Integrations/WordPress/Menus/MenuReadAbilitiesTest.php,tests/Unit/Integrations/WordPress/Menus/MenuCreateAbilityTest.php,tests/Unit/Integrations/WordPress/Menus/MenuAssignLocationAbilityTest.php
Interfaces:
-
Consumes:
ChangeLog,ToolResult,Errors. -
Produces:
MenuTree(final, statisch):build(list<object> $items): list<Node>mitNode = {id: int, title: string, type: 'post'|'taxonomy'|'custom', object: string, object_id: int, url: string, children: list<Node>}(Einträge mit fehlendem Elternteil landen auf oberster Ebene);node(object $item): array(ohnechildren);nodeSchema(): array.MenuLocations(final, statisch):registered(): array<string, string>,assigned(): array<string, int>,of(int $menuId): list<string>,assign(string $location, int $menuId): void(0= Zuordnung entfernen).MenuListAbility()–lit/menu-list, Output{menus: list<{id, name, slug, count, locations}>, locations: list<{location, description, menu_id|null}>}; indirekt; readonly.MenuGetAbility()–lit/menu-get, Input{menu_id}, Output{id, name, locations, items: list<Node>}; indirekt; readonly.MenuCreateAbility(ChangeLog $log)–lit/menu-create, Input{name, location?}; Ergebnis-Extramenu_id; indirekt; nicht idempotent.MenuAssignLocationAbility(ChangeLog $log)–lit/menu-assign-location, Input{location, menu_id}(0entfernt); Ergebnis-Extraprevious_menu_id(int|null); indirekt; idempotent.
-
Step 1: Failing Tests
tests/Unit/Integrations/WordPress/Menus/MenuTreeTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Menus;
use LIT\MCP\Integrations\WordPress\Menus\MenuTree;
use LIT\MCP\Tests\Unit\TestCase;
final class MenuTreeTest extends TestCase
{
public static function item(int $id, int $parent, string $title, string $type = 'post_type', string $url = ''): object
{
return (object) [
'ID' => $id,
'menu_item_parent' => (string) $parent,
'title' => $title,
'post_title' => $type === 'custom' ? $title : '',
'type' => $type,
'object' => $type === 'custom' ? 'custom' : 'page',
'object_id' => (string) ($type === 'custom' ? $id : $id + 100),
'url' => $url,
'description' => '',
'attr_title' => '',
'target' => '',
'classes' => [''],
'xfn' => '',
];
}
public function testBuildsANestedTreeInMenuOrder(): void
{
$tree = MenuTree::build([
self::item(10, 0, 'Start'),
self::item(11, 0, 'Über uns'),
self::item(12, 11, 'Team'),
self::item(13, 0, 'Kontakt', 'custom', '/kontakt'),
]);
self::assertSame(['Start', 'Über uns', 'Kontakt'], array_column($tree, 'title'));
self::assertSame('Team', $tree[1]['children'][0]['title']);
self::assertSame([], $tree[1]['children'][0]['children']);
self::assertSame('custom', $tree[2]['type']);
self::assertSame('post', $tree[0]['type']);
self::assertSame(110, $tree[0]['object_id']);
}
public function testOrphansAndSelfParentsLandOnTheTopLevel(): void
{
$tree = MenuTree::build([self::item(20, 99, 'Waise'), self::item(21, 21, 'Zirkel')]);
self::assertSame(['Waise', 'Zirkel'], array_column($tree, 'title'));
}
}
tests/Unit/Integrations/WordPress/Menus/MenuReadAbilitiesTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Menus;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\WordPress\Menus\MenuGetAbility;
use LIT\MCP\Integrations\WordPress\Menus\MenuListAbility;
use LIT\MCP\Tests\Unit\TestCase;
final class MenuReadAbilitiesTest extends TestCase
{
public function testListShowsMenusAndLocations(): void
{
Functions\when('wp_get_nav_menus')->justReturn([
(object) ['term_id' => 3, 'name' => 'Hauptmenü', 'slug' => 'hauptmenue', 'count' => 4],
]);
Functions\when('get_registered_nav_menus')->justReturn(['primary-menu' => 'Hauptmenü', 'footer-menu' => 'Footer']);
Functions\when('get_nav_menu_locations')->justReturn(['primary-menu' => 3]);
$result = (new MenuListAbility())->execute([]);
self::assertSame(['primary-menu'], $result['menus'][0]['locations']);
self::assertSame(
[
['location' => 'primary-menu', 'description' => 'Hauptmenü', 'menu_id' => 3],
['location' => 'footer-menu', 'description' => 'Footer', 'menu_id' => null],
],
$result['locations']
);
}
public function testGetReturnsTheTreeOrNotFound(): void
{
Functions\when('wp_get_nav_menu_object')->alias(
static fn (int $id) => $id === 3 ? (object) ['term_id' => 3, 'name' => 'Hauptmenü'] : false
);
Functions\when('wp_get_nav_menu_items')->justReturn([MenuTreeTest::item(10, 0, 'Start')]);
Functions\when('get_nav_menu_locations')->justReturn([]);
$ability = new MenuGetAbility();
$result = $ability->execute(['menu_id' => 3]);
self::assertSame('Hauptmenü', $result['name']);
self::assertSame('Start', $result['items'][0]['title']);
self::assertSame('lit_not_found', $ability->execute(['menu_id' => 9])->get_error_code());
}
public function testReadToolsNeedThemeOptionRights(): void
{
Functions\when('current_user_can')->justReturn(false);
self::assertSame('lit_forbidden', (new MenuListAbility())->permission([])->get_error_code());
self::assertSame('lit_forbidden', (new MenuGetAbility())->permission(['menu_id' => 3])->get_error_code());
}
}
tests/Unit/Integrations/WordPress/Menus/MenuCreateAbilityTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Menus;
use Brain\Monkey\Functions;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\Menus\MenuCreateAbility;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Error;
final class MenuCreateAbilityTest extends TestCase
{
public function testUnknownLocationIsRejectedBeforeCreating(): void
{
Functions\when('get_registered_nav_menus')->justReturn(['primary-menu' => 'Hauptmenü']);
Functions\expect('wp_create_nav_menu')->never();
$result = (new MenuCreateAbility(Mockery::mock(ChangeLog::class)))->execute(['name' => 'Neu', 'location' => 'nope']);
self::assertSame('lit_validation_failed', $result->get_error_code());
self::assertStringContainsString('primary-menu', $result->get_error_message());
}
public function testExistingNameIsAValidationError(): void
{
Functions\when('wp_create_nav_menu')->justReturn(new WP_Error('menu_exists', 'Der Menüname existiert bereits.'));
$result = (new MenuCreateAbility(Mockery::mock(ChangeLog::class)))->execute(['name' => 'Hauptmenü']);
self::assertSame('lit_validation_failed', $result->get_error_code());
}
public function testCreatesAndAssignsTheLocation(): void
{
Functions\when('get_registered_nav_menus')->justReturn(['primary-menu' => 'Hauptmenü']);
Functions\expect('wp_create_nav_menu')->once()->with('Neu')->andReturn(8);
Functions\when('get_nav_menu_locations')->justReturn(['footer-menu' => 2]);
Functions\expect('set_theme_mod')->once()->with('nav_menu_locations', ['footer-menu' => 2, 'primary-menu' => 8]);
$log = Mockery::mock(ChangeLog::class);
$log->expects('record')->with('lit/menu-create', 'nav_menu', '8', 'Menü "Neu" angelegt (Position primary-menu)')->andReturn(5);
$result = (new MenuCreateAbility($log))->execute(['name' => 'Neu', 'location' => 'primary-menu']);
self::assertSame(8, $result['menu_id']);
self::assertSame('created', $result['changed'][0]['action']);
}
}
tests/Unit/Integrations/WordPress/Menus/MenuAssignLocationAbilityTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Menus;
use Brain\Monkey\Functions;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\Menus\MenuAssignLocationAbility;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class MenuAssignLocationAbilityTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Functions\when('get_registered_nav_menus')->justReturn(['primary-menu' => 'Hauptmenü']);
Functions\when('get_nav_menu_locations')->justReturn(['primary-menu' => 3]);
Functions\when('wp_get_nav_menu_object')->alias(static fn (int $id) => $id === 8 ? (object) ['term_id' => 8, 'name' => 'Neu'] : false);
}
public function testAssignsAndReportsThePreviousMenu(): void
{
Functions\expect('set_theme_mod')->once()->with('nav_menu_locations', ['primary-menu' => 8]);
$log = Mockery::mock(ChangeLog::class);
$log->expects('record')->andReturn(6);
$result = (new MenuAssignLocationAbility($log))->execute(['location' => 'primary-menu', 'menu_id' => 8]);
self::assertSame(3, $result['previous_menu_id']);
self::assertSame('assigned', $result['changed'][0]['action']);
}
public function testZeroRemovesTheAssignment(): void
{
Functions\expect('set_theme_mod')->once()->with('nav_menu_locations', []);
$log = Mockery::mock(ChangeLog::class);
$log->expects('record')->andReturn(7);
$result = (new MenuAssignLocationAbility($log))->execute(['location' => 'primary-menu', 'menu_id' => 0]);
self::assertSame('unassigned', $result['changed'][0]['action']);
}
public function testUnknownMenuIsNotFound(): void
{
Functions\expect('set_theme_mod')->never();
$result = (new MenuAssignLocationAbility(Mockery::mock(ChangeLog::class)))
->execute(['location' => 'primary-menu', 'menu_id' => 99]);
self::assertSame('lit_not_found', $result->get_error_code());
}
}
In WordPressIntegrationTest die Konstante ersetzen (Reihenfolge wie in abilities()):
private const EXPECTED = [
'lit/content-types',
'lit/content-query',
'lit/content-get',
'lit/content-create',
'lit/content-update',
'lit/post-publish',
'lit/post-unpublish',
'lit/post-schedule',
'lit/post-trash',
'lit/post-duplicate',
'lit/post-replace-with-copy',
'lit/revisions-list',
'lit/revision-restore',
'lit/menu-list',
'lit/menu-get',
'lit/menu-create',
'lit/menu-assign-location',
];
- Step 2: Run – muss fehlschlagen
Run: composer test -- --filter 'Menu|WordPressIntegrationTest'
Expected: FAIL.
- Step 3: Implementierung
src/Integrations/WordPress/Menus/MenuTree.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Menus;
/**
* Turns the flat wp_get_nav_menu_items() list into a nested tree.
*/
final class MenuTree
{
/**
* @param list<object> $items Sorted by menu_order (wp_get_nav_menu_items default).
* @return list<array<string, mixed>>
*/
public static function build(array $items): array
{
$nodes = [];
foreach ($items as $item) {
$nodes[(int) $item->ID] = self::node($item);
}
$children = [];
foreach ($items as $item) {
$id = (int) $item->ID;
$parent = (int) $item->menu_item_parent;
$children[$parent !== $id && isset($nodes[$parent]) ? $parent : 0][] = $id;
}
return self::attach(0, $nodes, $children);
}
/** @return array{id: int, title: string, type: string, object: string, object_id: int, url: string} */
public static function node(object $item): array
{
return [
'id' => (int) $item->ID,
'title' => (string) $item->title,
'type' => match ((string) $item->type) {
'post_type' => 'post',
'taxonomy' => 'taxonomy',
default => 'custom',
},
'object' => (string) $item->object,
'object_id' => (int) $item->object_id,
'url' => (string) $item->url,
];
}
/** @return array<string, mixed> JSON schema of one tree node (children not validated deeper). */
public static function nodeSchema(): array
{
return [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer'],
'title' => ['type' => 'string'],
'type' => ['type' => 'string'],
'object' => ['type' => 'string'],
'object_id' => ['type' => 'integer'],
'url' => ['type' => 'string'],
'children' => ['type' => 'array'],
],
'required' => ['id', 'title', 'type', 'children'],
];
}
/**
* @param array<int, array<string, mixed>> $nodes
* @param array<int, list<int>> $children
* @return list<array<string, mixed>>
*/
private static function attach(int $parent, array $nodes, array $children): array
{
$result = [];
foreach ($children[$parent] ?? [] as $id) {
$result[] = array_merge($nodes[$id], ['children' => self::attach($id, $nodes, $children)]);
}
return $result;
}
}
src/Integrations/WordPress/Menus/MenuLocations.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Menus;
/**
* Theme menu locations (theme mod nav_menu_locations).
*/
final class MenuLocations
{
/** @return array<string, string> Location slug => description. */
public static function registered(): array
{
return array_map('strval', (array) get_registered_nav_menus());
}
/** @return array<string, int> Location slug => menu ID. */
public static function assigned(): array
{
$locations = get_nav_menu_locations();
return is_array($locations) ? array_map('intval', $locations) : [];
}
/** @return list<string> */
public static function of(int $menuId): array
{
return array_map('strval', array_keys(array_filter(
self::assigned(),
static fn (int $id): bool => $id === $menuId
)));
}
public static function assign(string $location, int $menuId): void
{
$locations = self::assigned();
if ($menuId > 0) {
$locations[$location] = $menuId;
} else {
unset($locations[$location]);
}
set_theme_mod('nav_menu_locations', $locations);
}
}
src/Integrations/WordPress/Menus/MenuListAbility.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Menus;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use WP_Error;
final class MenuListAbility implements Ability
{
public function name(): string
{
return 'lit/menu-list';
}
public function label(): string
{
return 'Menüs auflisten';
}
public function description(): string
{
return 'Listet alle Menüs mit Anzahl der Einträge sowie die Menü-Positionen des Themes und welches '
. 'Menü dort zugewiesen ist.';
}
public function inputSchema(): array
{
return ['type' => 'object', 'properties' => [], 'additionalProperties' => false];
}
public function outputSchema(): ?array
{
return [
'type' => 'object',
'properties' => [
'menus' => ['type' => 'array', 'items' => ['type' => 'object']],
'locations' => ['type' => 'array', 'items' => ['type' => 'object']],
],
'required' => ['menus', 'locations'],
];
}
public function annotations(): array
{
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
return current_user_can('edit_theme_options')
? true
: Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).');
}
public function execute(array $input): mixed
{
$menus = [];
foreach ((array) wp_get_nav_menus() as $menu) {
$menus[] = [
'id' => (int) $menu->term_id,
'name' => (string) $menu->name,
'slug' => (string) $menu->slug,
'count' => (int) $menu->count,
'locations' => MenuLocations::of((int) $menu->term_id),
];
}
$assigned = MenuLocations::assigned();
$locations = [];
foreach (MenuLocations::registered() as $location => $description) {
$menuId = $assigned[$location] ?? 0;
$locations[] = [
'location' => (string) $location,
'description' => $description,
'menu_id' => $menuId > 0 ? $menuId : null,
];
}
return ['menus' => $menus, 'locations' => $locations];
}
}
src/Integrations/WordPress/Menus/MenuGetAbility.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Menus;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use WP_Error;
final class MenuGetAbility implements Ability
{
public function name(): string
{
return 'lit/menu-get';
}
public function label(): string
{
return 'Menü lesen';
}
public function description(): string
{
return 'Liefert ein Menü als Baum (id, title, type post/taxonomy/custom, object_id, url, children). '
. 'Der Baum kann verändert an lit-menu-set-items zurückgegeben werden.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => ['menu_id' => ['type' => 'integer', 'minimum' => 1]],
'required' => ['menu_id'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer'],
'name' => ['type' => 'string'],
'locations' => ['type' => 'array', 'items' => ['type' => 'string']],
'items' => ['type' => 'array', 'items' => MenuTree::nodeSchema()],
],
'required' => ['id', 'name', 'locations', 'items'],
];
}
public function annotations(): array
{
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
return current_user_can('edit_theme_options')
? true
: Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).');
}
public function execute(array $input): mixed
{
$menuId = (int) ($input['menu_id'] ?? 0);
$menu = wp_get_nav_menu_object($menuId);
if (! is_object($menu)) {
return Errors::notFound(sprintf('Menü %d nicht gefunden.', $menuId), 'Menüs über lit-menu-list abrufen.');
}
$items = wp_get_nav_menu_items($menuId);
return [
'id' => (int) $menu->term_id,
'name' => (string) $menu->name,
'locations' => MenuLocations::of((int) $menu->term_id),
'items' => MenuTree::build(is_array($items) ? $items : []),
];
}
}
src/Integrations/WordPress/Menus/MenuCreateAbility.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Menus;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use WP_Error;
final class MenuCreateAbility implements Ability
{
public function __construct(private ChangeLog $log)
{
}
public function name(): string
{
return 'lit/menu-create';
}
public function label(): string
{
return 'Menü anlegen';
}
public function description(): string
{
return 'Legt ein leeres Menü an und weist es optional einer Theme-Position zu. Einträge danach mit '
. 'lit-menu-set-items setzen.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'name' => ['type' => 'string', 'minLength' => 1],
'location' => ['type' => 'string', 'description' => 'Theme-Position aus lit-menu-list.'],
],
'required' => ['name'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema(['menu_id' => ['type' => 'integer']], ['menu_id']);
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => false, 'idempotent' => false];
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
return current_user_can('edit_theme_options')
? true
: Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).');
}
public function execute(array $input): mixed
{
$name = trim((string) ($input['name'] ?? ''));
$location = isset($input['location']) ? (string) $input['location'] : '';
if ($location !== '' && ! array_key_exists($location, MenuLocations::registered())) {
return Errors::invalidInput(
'Unbekannte Menü-Position.',
['location' => 'verfügbar: ' . implode(', ', array_keys(MenuLocations::registered()))]
);
}
$menuId = wp_create_nav_menu($name);
if ($menuId instanceof WP_Error) {
return Errors::invalidInput(
'Menü konnte nicht angelegt werden: ' . $menuId->get_error_message(),
['name' => 'bereits vergeben oder ungültig'],
'Vorhandene Menüs über lit-menu-list abrufen.'
);
}
$menuId = (int) $menuId;
if ($location !== '') {
MenuLocations::assign($location, $menuId);
}
$summary = sprintf('Menü "%s" angelegt', $name)
. ($location !== '' ? sprintf(' (Position %s)', $location) : '');
$logId = $this->log->record($this->name(), 'nav_menu', (string) $menuId, $summary);
return ToolResult::ok($summary . sprintf(', ID %d.', $menuId))
->changed((string) $menuId, 'nav_menu', 'created', $name)
->logId($logId)
->with('menu_id', $menuId)
->toArray();
}
}
src/Integrations/WordPress/Menus/MenuAssignLocationAbility.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Menus;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use WP_Error;
final class MenuAssignLocationAbility implements Ability
{
public function __construct(private ChangeLog $log)
{
}
public function name(): string
{
return 'lit/menu-assign-location';
}
public function label(): string
{
return 'Menü-Position zuweisen';
}
public function description(): string
{
return 'Weist einer Theme-Position (siehe lit-menu-list) ein Menü zu; menu_id 0 entfernt die Zuweisung. '
. 'Das vorher zugewiesene Menü steht in previous_menu_id.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'location' => ['type' => 'string', 'minLength' => 1],
'menu_id' => ['type' => 'integer', 'minimum' => 0],
],
'required' => ['location', 'menu_id'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema(['previous_menu_id' => ['type' => ['integer', 'null']]], ['previous_menu_id']);
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
return current_user_can('edit_theme_options')
? true
: Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).');
}
public function execute(array $input): mixed
{
$location = (string) ($input['location'] ?? '');
$menuId = (int) ($input['menu_id'] ?? 0);
$registered = MenuLocations::registered();
if (! array_key_exists($location, $registered)) {
return Errors::invalidInput(
'Unbekannte Menü-Position.',
['location' => 'verfügbar: ' . implode(', ', array_keys($registered))]
);
}
if ($menuId > 0 && ! is_object(wp_get_nav_menu_object($menuId))) {
return Errors::notFound(sprintf('Menü %d nicht gefunden.', $menuId), 'Menüs über lit-menu-list abrufen.');
}
$previous = MenuLocations::assigned()[$location] ?? 0;
MenuLocations::assign($location, $menuId);
$action = $menuId > 0 ? 'assigned' : 'unassigned';
$summary = $menuId > 0
? sprintf('Menü %d der Position %s zugewiesen', $menuId, $location)
: sprintf('Zuweisung der Position %s entfernt', $location);
$logId = $this->log->record($this->name(), 'menu_location', $location, $summary);
return ToolResult::ok($summary . '.')
->changed($location, 'menu_location', $action, $registered[$location])
->logId($logId)
->with('previous_menu_id', $previous > 0 ? $previous : null)
->toArray();
}
}
src/Integrations/WordPress/WordPressIntegration.php: Imports ergänzen
use LIT\MCP\Integrations\WordPress\Menus\MenuAssignLocationAbility;
use LIT\MCP\Integrations\WordPress\Menus\MenuCreateAbility;
use LIT\MCP\Integrations\WordPress\Menus\MenuGetAbility;
use LIT\MCP\Integrations\WordPress\Menus\MenuListAbility;
und in abilities() nach new RevisionRestoreAbility(...) einfügen:
new MenuListAbility(),
new MenuGetAbility(),
new MenuCreateAbility($this->log),
new MenuAssignLocationAbility($this->log),
- Step 4: Run – muss bestehen
Run: composer test && composer lint
Expected: PASS.
- Step 5: Commit
git add src/Integrations/WordPress tests/Unit/Integrations/WordPress
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: menu list, get, create and location tools
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
Task 14: Menüeinträge setzen – MenuOperations, MenuWriter, lit/menu-set-items
Spec §6.4: Einträge deklarativ als Gesamtbaum oder als Einzeloperationen (hinzufügen/verschieben/entfernen). Beide Wege laufen durch denselben Schreibpfad: Operationen werden auf den aktuellen Baum angewendet und ergeben einen Zielbaum. Jede Entfernung braucht confirm: true; die Antwort enthält immer previous_items (Baum vorher), damit Claude einen Fehler zurückbauen kann. Menüeinträge (nav_menu_item) löscht WordPress selbst ohne Papierkorb (wp_delete_post($id, true), wie der Menü-Editor) – das ist die einzige Ausnahme von „nie endgültig löschen“, sie betrifft nur Menüstruktur, keine Inhalte.
Files:
- Create:
src/Integrations/WordPress/Menus/MenuOperations.php,src/Integrations/WordPress/Menus/MenuWriter.php,src/Integrations/WordPress/Menus/MenuSetItemsAbility.php - Modify:
src/Integrations/WordPress/WordPressIntegration.php,tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php - Test:
tests/Unit/Integrations/WordPress/Menus/MenuOperationsTest.php,tests/Unit/Integrations/WordPress/Menus/MenuWriterTest.php,tests/Unit/Integrations/WordPress/Menus/MenuSetItemsAbilityTest.php
Interfaces:
-
Consumes:
MenuTree::build()(Task 13),ChangeLog,ToolResult,Errors, Test-HelferMenuTreeTest::item(). -
Produces:
- Zielbaum-Knoten (Eingabe): bestehender Eintrag
{id, title?, url? (nur custom), children?}; neuer Eintrag{type: 'post'|'taxonomy'|'custom', object_id? (post/taxonomy), url? (custom), title? (custom: Pflicht), children?}. MenuOperations(final):apply(list<array> $tree, list<array> $operations): list<array>|WP_Error– Operationen{op: 'add', item: {…}, parent_id?, position?},{op: 'move', id, parent_id?, position?},{op: 'remove', id}(Kinder rücken an die Stelle des entfernten Eintrags);position= 0-basierter Index unter den Geschwistern, ohne = ans Ende.MenuWriter(nicht final):plan(list<mixed> $tree, array<int, object> $current): array{errors: array<string, string>, removals: list<array{id: int, title: string}>, warnings: list<string>}(schreibt nichts);apply(int $menuId, list<array> $tree, array<int, object> $current): array{created: int, updated: int, removed: int}(wirftRuntimeException).MenuSetItemsAbility:__construct(MenuWriter $writer, MenuOperations $operations, ChangeLog $log);lit/menu-set-items, Input{menu_id, items? | operations?, confirm?}; Ergebnis-Extrasitems(Baum nachher),previous_items(Baum vorher); indirekt;destructive: true, nicht idempotent.
- Zielbaum-Knoten (Eingabe): bestehender Eintrag
-
Step 1: Failing Tests
tests/Unit/Integrations/WordPress/Menus/MenuOperationsTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Menus;
use LIT\MCP\Integrations\WordPress\Menus\MenuOperations;
use LIT\MCP\Tests\Unit\TestCase;
final class MenuOperationsTest extends TestCase
{
/** @return list<array<string, mixed>> Start(1) > Team(2); Kontakt(3) */
private function tree(): array
{
return [
['id' => 1, 'title' => 'Start', 'children' => [['id' => 2, 'title' => 'Team', 'children' => []]]],
['id' => 3, 'title' => 'Kontakt', 'children' => []],
];
}
public function testAddInsertsANewNodeAtThePosition(): void
{
$result = (new MenuOperations())->apply($this->tree(), [
['op' => 'add', 'item' => ['type' => 'custom', 'url' => '/blog', 'title' => 'Blog'], 'position' => 1],
]);
self::assertSame(1, $result[0]['id']);
self::assertSame(['type' => 'custom', 'url' => '/blog', 'title' => 'Blog', 'children' => []], $result[1]);
self::assertSame(3, $result[2]['id']);
}
public function testMoveAndRemoveKeepChildren(): void
{
$result = (new MenuOperations())->apply($this->tree(), [
['op' => 'move', 'id' => 3, 'parent_id' => 1, 'position' => 0],
['op' => 'remove', 'id' => 1],
]);
self::assertSame([3, 2], array_column($result, 'id'));
self::assertSame([], $result[0]['children']);
}
public function testInvalidOperationsAreReportedWithTheirIndex(): void
{
$operations = new MenuOperations();
$unknown = $operations->apply($this->tree(), [['op' => 'move', 'id' => 99]]);
$cycle = $operations->apply($this->tree(), [['op' => 'move', 'id' => 1, 'parent_id' => 2]]);
$badOp = $operations->apply($this->tree(), [['op' => 'rename', 'id' => 1]]);
self::assertArrayHasKey('operations.0', $unknown->get_error_data()['fields']);
self::assertStringContainsString('unter sich selbst', $cycle->get_error_data()['fields']['operations.0']);
self::assertStringContainsString('add, move oder remove', $badOp->get_error_data()['fields']['operations.0']);
}
}
tests/Unit/Integrations/WordPress/Menus/MenuWriterTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Menus;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\WordPress\Menus\MenuWriter;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class MenuWriterTest extends TestCase
{
/** @return array<int, object> */
private function current(): array
{
return [
10 => MenuTreeTest::item(10, 0, 'Home', 'custom', '/'),
11 => MenuTreeTest::item(11, 0, 'Über uns'),
];
}
public function testPlanFindsErrorsRemovalsAndWarnings(): void
{
Functions\when('wp_parse_url')->alias('parse_url');
Functions\when('get_post')->alias(static fn (int $id) => $id === 7
? new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => 'draft', 'post_title' => 'Neu'])
: null);
$plan = (new MenuWriter())->plan([
['id' => 11, 'children' => [['id' => 11]]],
['id' => 55],
['type' => 'post', 'object_id' => 7],
['type' => 'custom', 'url' => 'javascript:alert(1)', 'title' => 'X'],
['type' => 'custom', 'url' => '/kontakt'],
], $this->current());
self::assertStringContainsString('mehrfach', $plan['errors']['items.0.children.0']);
self::assertStringContainsString('kein Eintrag dieses Menüs', $plan['errors']['items.1']);
self::assertArrayNotHasKey('items.2', $plan['errors']);
self::assertStringContainsString('url', $plan['errors']['items.3']);
self::assertStringContainsString('title', $plan['errors']['items.4']);
self::assertSame([['id' => 10, 'title' => 'Home']], $plan['removals']);
self::assertStringContainsString('"Neu" ist nicht veröffentlicht', $plan['warnings'][0]);
}
public function testApplyWritesInTreeOrderKeepsExistingDataAndRemovesTheRest(): void
{
Functions\when('esc_url_raw')->returnArg();
Functions\expect('wp_update_nav_menu_item')->once()->with(3, 11, Mockery::on(static fn (array $args): bool =>
$args['menu-item-title'] === 'Wir'
&& $args['menu-item-type'] === 'post_type'
&& $args['menu-item-object-id'] === 111
&& $args['menu-item-parent-id'] === 0
&& $args['menu-item-position'] === 1
&& $args['menu-item-status'] === 'publish'))->andReturn(11);
Functions\expect('wp_update_nav_menu_item')->once()->with(3, 0, Mockery::on(static fn (array $args): bool =>
$args['menu-item-type'] === 'custom'
&& $args['menu-item-url'] === '/kontakt'
&& $args['menu-item-title'] === 'Kontakt'
&& $args['menu-item-parent-id'] === 11
&& $args['menu-item-position'] === 2))->andReturn(12);
Functions\expect('wp_delete_post')->once()->with(10, true);
$stats = (new MenuWriter())->apply(3, [
['id' => 11, 'title' => 'Wir', 'children' => [['type' => 'custom', 'url' => '/kontakt', 'title' => 'Kontakt']]],
], $this->current());
self::assertSame(['created' => 1, 'updated' => 1, 'removed' => 1], $stats);
}
}
tests/Unit/Integrations/WordPress/Menus/MenuSetItemsAbilityTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Menus;
use Brain\Monkey\Functions;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\Menus\MenuOperations;
use LIT\MCP\Integrations\WordPress\Menus\MenuSetItemsAbility;
use LIT\MCP\Integrations\WordPress\Menus\MenuWriter;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class MenuSetItemsAbilityTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Functions\when('wp_get_nav_menu_object')->justReturn((object) ['term_id' => 3, 'name' => 'Hauptmenü']);
Functions\when('wp_get_nav_menu_items')->justReturn([MenuTreeTest::item(10, 0, 'Home', 'custom', '/')]);
}
public function testItemsAndOperationsAreMutuallyExclusive(): void
{
$ability = new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), Mockery::mock(ChangeLog::class));
self::assertSame('lit_validation_failed', $ability->execute(['menu_id' => 3])->get_error_code());
self::assertSame(
'lit_validation_failed',
$ability->execute(['menu_id' => 3, 'items' => [], 'operations' => [['op' => 'remove', 'id' => 10]]])->get_error_code()
);
}
public function testRemovalsNeedConfirmationAndNothingIsWritten(): void
{
Functions\expect('wp_update_nav_menu_item')->never();
Functions\expect('wp_delete_post')->never();
$ability = new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), Mockery::mock(ChangeLog::class));
$result = $ability->execute(['menu_id' => 3, 'items' => []]);
self::assertSame('lit_confirm_required', $result->get_error_code());
self::assertSame([['id' => 10, 'title' => 'Home']], $result->get_error_data()['removals']);
}
public function testAppliesAndReturnsTheTreeBeforeAndAfter(): void
{
$writer = Mockery::mock(MenuWriter::class);
$writer->allows('plan')->andReturn(['errors' => [], 'removals' => [], 'warnings' => []]);
$writer->expects('apply')->andReturn(['created' => 1, 'updated' => 1, 'removed' => 0]);
$log = Mockery::mock(ChangeLog::class);
$log->expects('record')->with('lit/menu-set-items', 'nav_menu', '3', Mockery::type('string'))->andReturn(21);
$result = (new MenuSetItemsAbility($writer, new MenuOperations(), $log))->execute([
'menu_id' => 3,
'operations' => [['op' => 'add', 'item' => ['type' => 'custom', 'url' => '/blog', 'title' => 'Blog']]],
]);
self::assertSame('Home', $result['previous_items'][0]['title']);
self::assertSame(21, $result['log_id']);
self::assertStringContainsString('1 neu, 1 geändert, 0 entfernt', $result['summary']);
}
}
In WordPressIntegrationTest die Konstante um 'lit/menu-set-items' nach 'lit/menu-create' ergänzen:
private const EXPECTED = [
'lit/content-types',
'lit/content-query',
'lit/content-get',
'lit/content-create',
'lit/content-update',
'lit/post-publish',
'lit/post-unpublish',
'lit/post-schedule',
'lit/post-trash',
'lit/post-duplicate',
'lit/post-replace-with-copy',
'lit/revisions-list',
'lit/revision-restore',
'lit/menu-list',
'lit/menu-get',
'lit/menu-create',
'lit/menu-set-items',
'lit/menu-assign-location',
];
- Step 2: Run – muss fehlschlagen
Run: composer test -- --filter 'MenuOperations|MenuWriter|MenuSetItems|WordPressIntegrationTest'
Expected: FAIL.
- Step 3: Implementierung
src/Integrations/WordPress/Menus/MenuOperations.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Menus;
use LIT\MCP\Abilities\Errors;
use WP_Error;
/**
* Applies add/move/remove operations to the current menu tree and returns the
* desired tree for MenuWriter – so single operations and the declarative mode
* share one validated write path.
*/
final class MenuOperations
{
private const ROOT = 'root';
/**
* @param list<array<string, mixed>> $tree Current tree (MenuTree::build()).
* @param list<mixed> $operations
* @return list<array<string, mixed>>|WP_Error
*/
public function apply(array $tree, array $operations): array|WP_Error
{
$nodes = [];
$children = [self::ROOT => []];
$this->flatten($tree, self::ROOT, $nodes, $children);
foreach ($operations as $index => $operation) {
$error = $this->applyOne($operation, (int) $index, $nodes, $children);
if ($error !== null) {
return Errors::invalidInput(
'Menü-Operation ungültig; nichts wurde geändert.',
['operations.' . $index => $error]
);
}
}
return $this->nest(self::ROOT, $nodes, $children);
}
/**
* @param list<array<string, mixed>> $tree
* @param array<string, array<string, mixed>> $nodes
* @param array<string, list<string>> $children
*/
private function flatten(array $tree, string $parent, array &$nodes, array &$children): void
{
foreach ($tree as $item) {
$key = 'id:' . (int) $item['id'];
$nodes[$key] = ['id' => (int) $item['id']];
$children[$parent][] = $key;
$children[$key] = [];
$this->flatten((array) ($item['children'] ?? []), $key, $nodes, $children);
}
}
/**
* @param array<string, array<string, mixed>> $nodes
* @param array<string, list<string>> $children
*/
private function applyOne(mixed $operation, int $index, array &$nodes, array &$children): ?string
{
if (! is_array($operation)) {
return 'Objekt erwartet.';
}
$parentId = (int) ($operation['parent_id'] ?? 0);
$parentKey = $parentId > 0 ? 'id:' . $parentId : self::ROOT;
if (! isset($children[$parentKey])) {
return 'parent_id ist kein Eintrag dieses Menüs.';
}
$position = isset($operation['position']) ? (int) $operation['position'] : null;
$key = 'id:' . (int) ($operation['id'] ?? 0);
switch ($operation['op'] ?? '') {
case 'add':
if (! is_array($operation['item'] ?? null)) {
return 'item fehlt.';
}
$newKey = 'new:' . $index;
$nodes[$newKey] = array_diff_key($operation['item'], ['id' => true, 'children' => true]);
$children[$newKey] = [];
$this->insert($children[$parentKey], $newKey, $position);
return null;
case 'move':
if (! isset($nodes[$key])) {
return 'id ist kein Eintrag dieses Menüs.';
}
if ($key === $parentKey || $this->isDescendant($parentKey, $key, $children)) {
return 'Ein Eintrag kann nicht unter sich selbst verschoben werden.';
}
$this->detach($key, $children);
$this->insert($children[$parentKey], $key, $position);
return null;
case 'remove':
if (! isset($nodes[$key])) {
return 'id ist kein Eintrag dieses Menüs.';
}
[$owner, $at] = $this->detach($key, $children);
array_splice($children[$owner], $at, 0, $children[$key]);
unset($nodes[$key], $children[$key]);
return null;
default:
return 'op muss add, move oder remove sein.';
}
}
/** @param list<string> $list */
private function insert(array &$list, string $key, ?int $position): void
{
if ($position === null || $position >= count($list)) {
$list[] = $key;
return;
}
array_splice($list, max(0, $position), 0, [$key]);
}
/**
* @param array<string, list<string>> $children
* @return array{0: string, 1: int} Former parent key and index.
*/
private function detach(string $key, array &$children): array
{
foreach ($children as $parent => $list) {
$index = array_search($key, $list, true);
if ($index !== false) {
array_splice($children[$parent], (int) $index, 1);
return [(string) $parent, (int) $index];
}
}
return [self::ROOT, count($children[self::ROOT])];
}
/** @param array<string, list<string>> $children */
private function isDescendant(string $candidate, string $ancestor, array $children): bool
{
foreach ($children[$ancestor] ?? [] as $child) {
if ($child === $candidate || $this->isDescendant($candidate, $child, $children)) {
return true;
}
}
return false;
}
/**
* @param array<string, array<string, mixed>> $nodes
* @param array<string, list<string>> $children
* @return list<array<string, mixed>>
*/
private function nest(string $parent, array $nodes, array $children): array
{
$result = [];
foreach ($children[$parent] ?? [] as $key) {
$result[] = array_merge($nodes[$key], ['children' => $this->nest($key, $nodes, $children)]);
}
return $result;
}
}
src/Integrations/WordPress/Menus/MenuWriter.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Menus;
use RuntimeException;
use WP_Post;
use WP_Term;
/**
* Validates a desired menu tree against the current items and writes it.
* Existing items keep all their data (classes, target, description, …) unless
* the node overrides title/url. Not final so the ability can be tested with a mock.
*/
class MenuWriter
{
private const NON_LINKABLE = ['revision', 'nav_menu_item', 'attachment'];
private const URL_SCHEMES = ['http', 'https', 'mailto', 'tel'];
/**
* @param list<mixed> $tree
* @param array<int, object> $current Current items by ID.
* @return array{errors: array<string, string>, removals: list<array{id: int, title: string}>, warnings: list}
*/
public function plan(array $tree, array $current): array
{
$errors = [];
$warnings = [];
$seen = [];
$this->validateNodes($tree, 'items', $current, $seen, $errors, $warnings);
$removals = [];
foreach ($current as $id => $item) {
if (! isset($seen[$id])) {
$removals[] = ['id' => (int) $id, 'title' => (string) $item->title];
}
}
return ['errors' => $errors, 'removals' => $removals, 'warnings' => $warnings];
}
/**
* @param list<array<string, mixed>> $tree Validated by plan().
* @param array<int, object> $current
* @return array{created: int, updated: int, removed: int}
* @throws RuntimeException
*/
public function apply(int $menuId, array $tree, array $current): array
{
$stats = ['created' => 0, 'updated' => 0, 'removed' => 0];
$position = 0;
$seen = [];
$this->writeNodes($menuId, $tree, 0, $current, $position, $seen, $stats);
foreach (array_keys($current) as $id) {
if (! isset($seen[$id])) {
// Menu items have no trash in WordPress; the core menu editor deletes them the same way.
wp_delete_post((int) $id, true);
$stats['removed']++;
}
}
return $stats;
}
/**
* @param array<int, object> $current
* @param array<int, true> $seen
* @param array<string, string> $errors
* @param list<string> $warnings
*/
private function validateNodes(
mixed $nodes,
string $path,
array $current,
array &$seen,
array &$errors,
array &$warnings
): void {
if (! is_array($nodes) || ! array_is_list($nodes)) {
$errors[$path] = 'Liste erwartet.';
return;
}
foreach ($nodes as $index => $node) {
$nodePath = $path . '.' . $index;
if (! is_array($node)) {
$errors[$nodePath] = 'Objekt erwartet.';
continue;
}
$error = array_key_exists('id', $node)
? $this->checkExisting($node, $current, $seen)
: $this->checkNew($node, $warnings);
if ($error !== null) {
$errors[$nodePath] = $error;
}
if (array_key_exists('children', $node)) {
$this->validateNodes($node['children'], $nodePath . '.children', $current, $seen, $errors, $warnings);
}
}
}
/**
* @param array<string, mixed> $node
* @param array<int, object> $current
* @param array<int, true> $seen
*/
private function checkExisting(array $node, array $current, array &$seen): ?string
{
$id = $node['id'];
if (! is_int($id) || ! isset($current[$id])) {
return 'id ist kein Eintrag dieses Menüs (für neue Einträge id weglassen).';
}
if (isset($seen[$id])) {
return sprintf('Eintrag %d kommt mehrfach vor.', $id);
}
$seen[$id] = true;
if (array_key_exists('title', $node) && ! is_string($node['title'])) {
return 'title muss Text sein.';
}
if (array_key_exists('url', $node)) {
if ((string) $current[$id]->type !== 'custom') {
return 'url ist nur bei individuellen Links änderbar.';
}
if (! $this->validUrl($node['url'])) {
return 'Ungültige url (erlaubt: /pfad, #anker, http(s), mailto, tel).';
}
}
return null;
}
/**
* @param array<string, mixed> $node
* @param list<string> $warnings
*/
private function checkNew(array $node, array &$warnings): ?string
{
$objectId = $node['object_id'] ?? null;
switch ($node['type'] ?? null) {
case 'post':
$post = is_int($objectId) ? get_post($objectId) : null;
if (! $post instanceof WP_Post || in_array($post->post_type, self::NON_LINKABLE, true)) {
return 'object_id muss ein vorhandener Eintrag sein.';
}
if ($post->post_status !== 'publish') {
$warnings[] = sprintf(
'"%s" ist nicht veröffentlicht und erscheint erst nach dem Veröffentlichen im Menü.',
$post->post_title
);
}
return null;
case 'taxonomy':
return is_int($objectId) && get_term($objectId) instanceof WP_Term
? null
: 'object_id muss ein vorhandener Begriff sein.';
case 'custom':
if (! $this->validUrl($node['url'] ?? null)) {
return 'Individueller Link braucht eine gültige url (/pfad, #anker, http(s), mailto, tel).';
}
return is_string($node['title'] ?? null) && trim($node['title']) !== ''
? null
: 'Individueller Link braucht einen title.';
default:
return 'type muss post, taxonomy oder custom sein.';
}
}
private function validUrl(mixed $url): bool
{
if (! is_string($url) || $url === '' || preg_match('/\s/', $url) === 1) {
return false;
}
if (str_starts_with($url, '/') || str_starts_with($url, '#')) {
return true;
}
$scheme = wp_parse_url($url, PHP_URL_SCHEME);
return is_string($scheme) && in_array(strtolower($scheme), self::URL_SCHEMES, true);
}
/**
* @param list<array<string, mixed>> $nodes
* @param array<int, object> $current
* @param array<int, true> $seen
* @param array{created: int, updated: int, removed: int} $stats
*/
private function writeNodes(
int $menuId,
array $nodes,
int $parentId,
array $current,
int &$position,
array &$seen,
array &$stats
): void {
foreach ($nodes as $node) {
$position++;
$existing = isset($node['id']) ? $current[(int) $node['id']] : null;
$args = $existing !== null ? $this->argsFromItem($existing) : $this->argsFromNode($node);
if (isset($node['title'])) {
$args['menu-item-title'] = (string) $node['title'];
}
if (isset($node['url'])) {
$args['menu-item-url'] = esc_url_raw((string) $node['url']);
}
$args['menu-item-parent-id'] = $parentId;
$args['menu-item-position'] = $position;
$args['menu-item-status'] = 'publish';
$itemId = wp_update_nav_menu_item($menuId, $existing !== null ? (int) $existing->ID : 0, $args);
if (is_wp_error($itemId)) {
throw new RuntimeException('Menu item could not be saved: ' . $itemId->get_error_message());
}
if ($existing !== null) {
$seen[(int) $existing->ID] = true;
$stats['updated']++;
} else {
$stats['created']++;
}
$children = (array) ($node['children'] ?? []);
$this->writeNodes($menuId, $children, (int) $itemId, $current, $position, $seen, $stats);
}
}
/** @return array<string, mixed> */
private function argsFromItem(object $item): array
{
$isCustom = (string) $item->type === 'custom';
return [
'menu-item-object-id' => (int) $item->object_id,
'menu-item-object' => (string) $item->object,
'menu-item-type' => (string) $item->type,
'menu-item-title' => (string) $item->post_title,
'menu-item-url' => $isCustom ? (string) $item->url : '',
'menu-item-description' => (string) $item->description,
'menu-item-attr-title' => (string) $item->attr_title,
'menu-item-target' => (string) $item->target,
'menu-item-classes' => implode(' ', array_filter((array) $item->classes)),
'menu-item-xfn' => (string) $item->xfn,
];
}
/**
* @param array<string, mixed> $node
* @return array<string, mixed>
*/
private function argsFromNode(array $node): array
{
$objectId = (int) ($node['object_id'] ?? 0);
return match ($node['type']) {
'post' => [
'menu-item-type' => 'post_type',
'menu-item-object' => (string) get_post_type($objectId),
'menu-item-object-id' => $objectId,
],
'taxonomy' => [
'menu-item-type' => 'taxonomy',
'menu-item-object' => (string) get_term($objectId)->taxonomy,
'menu-item-object-id' => $objectId,
],
default => ['menu-item-type' => 'custom', 'menu-item-url' => ''],
};
}
}
src/Integrations/WordPress/Menus/MenuSetItemsAbility.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Menus;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use WP_Error;
final class MenuSetItemsAbility implements Ability
{
public function __construct(
private MenuWriter $writer,
private MenuOperations $operations,
private ChangeLog $log
) {
}
public function name(): string
{
return 'lit/menu-set-items';
}
public function label(): string
{
return 'Menüeinträge setzen';
}
public function description(): string
{
return 'Setzt die Einträge eines Menüs. Entweder items = vollständiger Zielbaum (bestehende Einträge mit '
. 'id aus lit-menu-get, neue mit type post|taxonomy|custom und object_id bzw. url+title, jeweils '
. 'optional children) oder operations = Liste aus {op: add, item, parent_id?, position?}, '
. '{op: move, id, parent_id?, position?}, {op: remove, id}. Einträge, die wegfallen, werden nur mit '
. 'confirm: true entfernt. Die Antwort enthält previous_items zum Zurückbauen.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'menu_id' => ['type' => 'integer', 'minimum' => 1],
'items' => ['type' => 'array', 'items' => ['type' => 'object']],
'operations' => [
'type' => 'array',
'minItems' => 1,
'items' => [
'type' => 'object',
'properties' => [
'op' => ['type' => 'string', 'enum' => ['add', 'move', 'remove']],
'id' => ['type' => 'integer', 'minimum' => 1],
'item' => ['type' => 'object'],
'parent_id' => ['type' => 'integer', 'minimum' => 0],
'position' => ['type' => 'integer', 'minimum' => 0],
],
'required' => ['op'],
'additionalProperties' => false,
],
],
'confirm' => ['type' => 'boolean'],
],
'required' => ['menu_id'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
$tree = ['type' => 'array', 'items' => MenuTree::nodeSchema()];
return ToolResult::schema(['items' => $tree, 'previous_items' => $tree], ['items', 'previous_items']);
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => true, 'idempotent' => false];
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
return current_user_can('edit_theme_options')
? true
: Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).');
}
public function execute(array $input): mixed
{
$menuId = (int) ($input['menu_id'] ?? 0);
$menu = wp_get_nav_menu_object($menuId);
if (! is_object($menu)) {
return Errors::notFound(sprintf('Menü %d nicht gefunden.', $menuId), 'Menüs über lit-menu-list abrufen.');
}
if (array_key_exists('items', $input) === array_key_exists('operations', $input)) {
return Errors::invalidInput(
'Entweder items (Gesamtbaum) oder operations angeben – genau eines von beiden.',
['items' => 'oder operations']
);
}
$currentItems = wp_get_nav_menu_items($menuId);
$current = [];
foreach (is_array($currentItems) ? $currentItems : [] as $item) {
$current[(int) $item->ID] = $item;
}
$previous = MenuTree::build(array_values($current));
$tree = array_key_exists('items', $input)
? (array) $input['items']
: $this->operations->apply($previous, (array) $input['operations']);
if ($tree instanceof WP_Error) {
return $tree;
}
$plan = $this->writer->plan($tree, $current);
if ($plan['errors'] !== []) {
return Errors::invalidInput('Menübaum ist ungültig; nichts wurde geändert.', $plan['errors']);
}
if ($plan['removals'] !== [] && ($input['confirm'] ?? false) !== true) {
return Errors::confirmRequired(
sprintf(
'Folgende Menüeinträge würden entfernt: %s.',
implode(', ', array_column($plan['removals'], 'title'))
),
['removals' => $plan['removals']]
);
}
$stats = $this->writer->apply($menuId, $tree, $current);
$after = wp_get_nav_menu_items($menuId);
$summary = sprintf(
'Menü "%s": %d neu, %d geändert, %d entfernt',
$menu->name,
$stats['created'],
$stats['updated'],
$stats['removed']
);
$logId = $this->log->record($this->name(), 'nav_menu', (string) $menuId, $summary);
return ToolResult::ok($summary . '.')
->changed((string) $menuId, 'nav_menu', 'updated', (string) $menu->name)
->warnings($plan['warnings'])
->logId($logId)
->with('items', MenuTree::build(is_array($after) ? $after : []))
->with('previous_items', $previous)
->toArray();
}
}
src/Integrations/WordPress/WordPressIntegration.php: Imports ergänzen
use LIT\MCP\Integrations\WordPress\Menus\MenuOperations;
use LIT\MCP\Integrations\WordPress\Menus\MenuSetItemsAbility;
use LIT\MCP\Integrations\WordPress\Menus\MenuWriter;
und in abilities() nach new MenuCreateAbility($this->log), einfügen:
new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), $this->log),
- Step 4: Run – muss bestehen
Run: composer test && composer lint
Expected: PASS (lange Zeilen umbrechen).
- Step 5: Commit
git add src/Integrations/WordPress tests/Unit/Integrations/WordPress
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: declarative and operation-based menu item editing
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
Task 15: WordPress-Einstellungen – SettingsWhitelist, lit/wp-settings-get, lit/wp-settings-update
Whitelist exakt nach Spec §6.5; Recht manage_options. Nach einer Änderung der Permalink-Struktur werden die Rewrite-Regeln neu erzeugt – über $wp_rewrite->set_permalink_structure() (setzt Option und Rewrite-Objekt gemeinsam), danach flush_rewrite_rules(); ein bloßes update_option() + Flush würde die Regeln mit der alten Struktur erzeugen.
Files:
- Create:
src/Integrations/WordPress/Settings/SettingsWhitelist.php,src/Integrations/WordPress/Settings/WpSettingsGetAbility.php,src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php - Modify:
src/Integrations/WordPress/WordPressIntegration.php,tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php - Test:
tests/Unit/Integrations/WordPress/Settings/SettingsWhitelistTest.php,tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php
Interfaces:
-
Consumes:
ChangeLog,ToolResult,JsonMap,Errors. -
Produces:
SettingsWhitelist(final):SETTINGS(Schlüssel →[Gruppe, Typ, Beschreibung]),BLOCKED,GROUPS;current(string $key): mixed(WPLANGleer →en_US; Zahlenfelder als int);validate(string $key, mixed $value, array $pending): ?string(null= gültig;$pending= alle Werte dieses Aufrufs, für Abhängigkeitenshow_on_front/page_on_front/page_for_posts);normalize(string $key, mixed $value): mixed(WPLANGen_US→'').WpSettingsGetAbility(SettingsWhitelist $whitelist)–lit/wp-settings-get, Input{group?}, Output{settings: list<{key, group, type, value, description}>}; indirekt; readonly.WpSettingsUpdateAbility(SettingsWhitelist $whitelist, ChangeLog $log)–lit/wp-settings-update, Input{settings: {key: value}}; alles oder nichts; Ergebnis-Extraprevious(vorherige Werte); indirekt; idempotent.
-
Step 1: Failing Tests
tests/Unit/Integrations/WordPress/Settings/SettingsWhitelistTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Settings;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\WordPress\Settings\SettingsWhitelist;
use LIT\MCP\Tests\Unit\TestCase;
use WP_Post;
final class SettingsWhitelistTest extends TestCase
{
public function testWhitelistMatchesTheSpecExactly(): void
{
self::assertSame(
[
'blogname', 'blogdescription', 'WPLANG', 'timezone_string', 'date_format', 'time_format',
'show_on_front', 'page_on_front', 'page_for_posts', 'posts_per_page',
'default_comment_status', 'default_ping_status', 'permalink_structure', 'site_icon',
],
array_keys(SettingsWhitelist::SETTINGS)
);
self::assertSame(['admin_email', 'users_can_register', 'default_role', 'siteurl', 'home'], SettingsWhitelist::BLOCKED);
}
public function testBlockedAndUnknownKeysAreRejected(): void
{
$whitelist = new SettingsWhitelist();
self::assertStringStartsWith('Gesperrt', (string) $whitelist->validate('admin_email', 'x@y.de', []));
self::assertStringStartsWith('Nicht freigegeben', (string) $whitelist->validate('template', 'twentyten', []));
}
public function testStaticFrontPageNeedsAPublishedDistinctPage(): void
{
Functions\when('get_option')->justReturn(0);
Functions\when('get_post')->alias(static fn (int $id) => match ($id) {
5 => new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_status' => 'publish']),
6 => new WP_Post(['ID' => 6, 'post_type' => 'page', 'post_status' => 'draft']),
default => null,
});
$whitelist = new SettingsWhitelist();
self::assertNotNull($whitelist->validate('show_on_front', 'page', []));
self::assertNull($whitelist->validate('show_on_front', 'page', ['page_on_front' => 5]));
self::assertNull($whitelist->validate('page_on_front', 5, []));
self::assertNotNull($whitelist->validate('page_on_front', 6, []));
self::assertNotNull($whitelist->validate('page_on_front', 5, ['page_for_posts' => 5]));
self::assertNull($whitelist->validate('page_for_posts', 0, []));
}
public function testFormatsLanguagesAndRanges(): void
{
Functions\when('get_available_languages')->justReturn(['de_DE']);
Functions\when('wp_attachment_is_image')->alias(static fn (int $id): bool => $id === 33);
$whitelist = new SettingsWhitelist();
self::assertNull($whitelist->validate('WPLANG', 'de_DE', []));
self::assertNull($whitelist->validate('WPLANG', 'en_US', []));
self::assertStringContainsString('de_DE', (string) $whitelist->validate('WPLANG', 'fr_FR', []));
self::assertNull($whitelist->validate('timezone_string', 'Europe/Berlin', []));
self::assertNotNull($whitelist->validate('timezone_string', 'Mars/Base', []));
self::assertNull($whitelist->validate('permalink_structure', '/%postname%/', []));
self::assertNull($whitelist->validate('permalink_structure', '', []));
self::assertNotNull($whitelist->validate('permalink_structure', 'postname', []));
self::assertNotNull($whitelist->validate('posts_per_page', 0, []));
self::assertNotNull($whitelist->validate('default_comment_status', 'maybe', []));
self::assertNull($whitelist->validate('site_icon', 33, []));
self::assertNotNull($whitelist->validate('site_icon', 34, []));
self::assertSame('', $whitelist->normalize('WPLANG', 'en_US'));
}
public function testCurrentNormalizesLanguageAndNumbers(): void
{
Functions\when('get_option')->alias(static fn (string $key) => ['WPLANG' => '', 'page_on_front' => '5'][$key]);
$whitelist = new SettingsWhitelist();
self::assertSame('en_US', $whitelist->current('WPLANG'));
self::assertSame(5, $whitelist->current('page_on_front'));
}
}
tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Settings;
use Brain\Monkey\Functions;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\Settings\SettingsWhitelist;
use LIT\MCP\Integrations\WordPress\Settings\WpSettingsGetAbility;
use LIT\MCP\Integrations\WordPress\Settings\WpSettingsUpdateAbility;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class WpSettingsAbilitiesTest extends TestCase
{
protected function tearDown(): void
{
unset($GLOBALS['wp_rewrite']);
parent::tearDown();
}
public function testGetFiltersByGroup(): void
{
Functions\when('get_option')->justReturn('Kunde');
$result = (new WpSettingsGetAbility(new SettingsWhitelist()))->execute(['group' => 'discussion']);
self::assertSame(['default_comment_status', 'default_ping_status'], array_column($result['settings'], 'key'));
self::assertSame('discussion', $result['settings'][0]['group']);
}
public function testUpdateIsAllOrNothing(): void
{
Functions\expect('update_option')->never();
$result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), Mockery::mock(ChangeLog::class)))
->execute(['settings' => ['blogname' => 'Neu', 'admin_email' => 'x@y.de']]);
self::assertSame('lit_validation_failed', $result->get_error_code());
self::assertArrayHasKey('admin_email', $result->get_error_data()['fields']);
}
public function testUpdatesOnlyChangedValuesAndFlushesPermalinks(): void
{
Functions\when('get_option')->alias(static fn (string $key) => [
'blogname' => 'Alt',
'blogdescription' => 'Gleich',
'permalink_structure' => '',
][$key]);
Functions\expect('update_option')->once()->with('blogname', 'Neu');
$rewrite = Mockery::mock();
$rewrite->expects('set_permalink_structure')->with('/%postname%/');
$GLOBALS['wp_rewrite'] = $rewrite;
Functions\expect('flush_rewrite_rules')->once();
$log = Mockery::mock(ChangeLog::class);
$log->expects('record')->with('lit/wp-settings-update', 'option', 'blogname,permalink_structure', Mockery::type('string'))->andReturn(30);
$result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), $log))->execute(['settings' => [
'blogname' => 'Neu',
'blogdescription' => 'Gleich',
'permalink_structure' => '/%postname%/',
]]);
self::assertSame(['blogname', 'permalink_structure'], array_column($result['changed'], 'id'));
self::assertSame(['blogname' => 'Alt', 'blogdescription' => 'Gleich', 'permalink_structure' => ''], $result['previous']);
self::assertSame(30, $result['log_id']);
}
public function testSettingsToolsNeedManageOptions(): void
{
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'manage_options');
self::assertSame('lit_forbidden', (new WpSettingsGetAbility(new SettingsWhitelist()))->permission([])->get_error_code());
self::assertSame(
'lit_forbidden',
(new WpSettingsUpdateAbility(new SettingsWhitelist(), Mockery::mock(ChangeLog::class)))->permission([])->get_error_code()
);
}
}
In WordPressIntegrationTest die Konstante um die beiden Einstellungs-Tools am Ende ergänzen:
private const EXPECTED = [
'lit/content-types',
'lit/content-query',
'lit/content-get',
'lit/content-create',
'lit/content-update',
'lit/post-publish',
'lit/post-unpublish',
'lit/post-schedule',
'lit/post-trash',
'lit/post-duplicate',
'lit/post-replace-with-copy',
'lit/revisions-list',
'lit/revision-restore',
'lit/menu-list',
'lit/menu-get',
'lit/menu-create',
'lit/menu-set-items',
'lit/menu-assign-location',
'lit/wp-settings-get',
'lit/wp-settings-update',
];
- Step 2: Run – muss fehlschlagen
Run: composer test -- --filter 'Settings|WordPressIntegrationTest'
Expected: FAIL.
- Step 3: Implementierung
src/Integrations/WordPress/Settings/SettingsWhitelist.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Settings;
use WP_Post;
/**
* The WordPress options LIT MCP may read and write (spec §6.5), with validation.
*/
final class SettingsWhitelist
{
/** key => [group, type, description] */
public const SETTINGS = [
'blogname' => ['general', 'string', 'Titel der Website'],
'blogdescription' => ['general', 'string', 'Untertitel der Website'],
'WPLANG' => ['general', 'string', 'Sprache (Locale, z. B. de_DE; en_US = Englisch); nur installierte Sprachen'],
'timezone_string' => ['general', 'string', 'Zeitzone, z. B. Europe/Berlin'],
'date_format' => ['general', 'string', 'Datumsformat (PHP), z. B. j. F Y'],
'time_format' => ['general', 'string', 'Zeitformat (PHP), z. B. H:i'],
'show_on_front' => ['reading', 'string', 'Startseite: posts (neueste Beiträge) oder page (statische Seite)'],
'page_on_front' => ['reading', 'integer', 'ID der statischen Startseite (0 = keine)'],
'page_for_posts' => ['reading', 'integer', 'ID der Beitragsseite (0 = keine)'],
'posts_per_page' => ['reading', 'integer', 'Beiträge pro Seite (1–100)'],
'default_comment_status' => ['discussion', 'string', 'Kommentare für neue Beiträge: open oder closed'],
'default_ping_status' => ['discussion', 'string', 'Pingbacks für neue Beiträge: open oder closed'],
'permalink_structure' => ['permalinks', 'string', 'Permalink-Struktur, z. B. /%postname%/ (leer = einfach)'],
'site_icon' => ['site_icon', 'integer', 'Anhang-ID des Website-Icons (Bild, empfohlen 512×512; 0 = keins)'],
];
public const BLOCKED = ['admin_email', 'users_can_register', 'default_role', 'siteurl', 'home'];
public const GROUPS = ['general', 'reading', 'discussion', 'permalinks', 'site_icon'];
private const INTEGER_KEYS = ['page_on_front', 'page_for_posts', 'posts_per_page', 'site_icon'];
public function current(string $key): mixed
{
$value = get_option($key);
if ($key === 'WPLANG') {
return $value === '' || $value === false ? 'en_US' : (string) $value;
}
return in_array($key, self::INTEGER_KEYS, true) ? (int) $value : (string) $value;
}
/**
* @param array<string, mixed> $pending All values of the current call.
* @return string|null Error message, null when valid.
*/
public function validate(string $key, mixed $value, array $pending): ?string
{
if (in_array($key, self::BLOCKED, true)) {
return 'Gesperrt: aus Sicherheitsgründen nicht über LIT MCP änderbar.';
}
if (! isset(self::SETTINGS[$key])) {
return 'Nicht freigegeben; erlaubt: ' . implode(', ', array_keys(self::SETTINGS)) . '.';
}
return match ($key) {
'blogname', 'blogdescription' => is_string($value) && mb_strlen($value) <= 200
? null
: 'Text bis 200 Zeichen erwartet.',
'WPLANG' => $this->checkLanguage($value),
'timezone_string' => is_string($value) && in_array($value, timezone_identifiers_list(), true)
? null
: 'Zeitzonen-Kennung wie Europe/Berlin erwartet.',
'date_format', 'time_format' => is_string($value) && trim($value) !== '' && mb_strlen($value) <= 50
? null
: 'Nicht leeres Format bis 50 Zeichen erwartet.',
'show_on_front' => $this->checkShowOnFront($value, $pending),
'page_on_front', 'page_for_posts' => $this->checkPage($key, $value, $pending),
'posts_per_page' => is_int($value) && $value >= 1 && $value <= 100
? null
: 'Ganzzahl von 1 bis 100 erwartet.',
'default_comment_status', 'default_ping_status' => in_array($value, ['open', 'closed'], true)
? null
: 'open oder closed erwartet.',
'permalink_structure' => $this->checkPermalink($value),
'site_icon' => $this->checkSiteIcon($value),
};
}
public function normalize(string $key, mixed $value): mixed
{
return $key === 'WPLANG' && $value === 'en_US' ? '' : $value;
}
private function checkLanguage(mixed $value): ?string
{
if ($value === 'en_US' || $value === '') {
return null;
}
$installed = get_available_languages();
return is_string($value) && in_array($value, $installed, true)
? null
: sprintf('Sprache nicht installiert; verfügbar: en_US, %s.', implode(', ', $installed));
}
/** @param array<string, mixed> $pending */
private function checkShowOnFront(mixed $value, array $pending): ?string
{
if ($value === 'posts') {
return null;
}
if ($value !== 'page') {
return 'posts oder page erwartet.';
}
$front = (int) ($pending['page_on_front'] ?? get_option('page_on_front'));
return $front > 0 ? null : 'Für page zuerst page_on_front setzen (im selben Aufruf möglich).';
}
/** @param array<string, mixed> $pending */
private function checkPage(string $key, mixed $value, array $pending): ?string
{
if (! is_int($value) || $value < 0) {
return 'Seiten-ID (0 = keine) erwartet.';
}
if ($value === 0) {
return null;
}
$page = get_post($value);
if (! $page instanceof WP_Post || $page->post_type !== 'page' || $page->post_status !== 'publish') {
return sprintf('%d ist keine veröffentlichte Seite.', $value);
}
$other = $key === 'page_on_front' ? 'page_for_posts' : 'page_on_front';
$otherValue = (int) ($pending[$other] ?? get_option($other));
return $otherValue === $value ? 'Startseite und Beitragsseite müssen verschiedene Seiten sein.' : null;
}
private function checkPermalink(mixed $value): ?string
{
if ($value === '') {
return null;
}
return is_string($value)
&& str_starts_with($value, '/')
&& str_contains($value, '%')
&& mb_strlen($value) <= 200
&& preg_match('/\s/', $value) !== 1
? null
: 'Leer oder eine Struktur wie /%postname%/ erwartet.';
}
private function checkSiteIcon(mixed $value): ?string
{
if (! is_int($value) || $value < 0) {
return 'Anhang-ID (0 = keins) erwartet.';
}
return $value === 0 || wp_attachment_is_image($value)
? null
: sprintf('%d ist kein Bild der Mediathek.', $value);
}
}
src/Integrations/WordPress/Settings/WpSettingsGetAbility.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Settings;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use WP_Error;
final class WpSettingsGetAbility implements Ability
{
public function __construct(private SettingsWhitelist $whitelist)
{
}
public function name(): string
{
return 'lit/wp-settings-get';
}
public function label(): string
{
return 'WordPress-Einstellungen lesen';
}
public function description(): string
{
return 'Liest die freigegebenen WordPress-Einstellungen (Allgemein, Lesen, Diskussion, Permalinks, '
. 'Website-Icon) mit Typ und Beschreibung. Ändern mit lit-wp-settings-update.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => ['group' => ['type' => 'string', 'enum' => SettingsWhitelist::GROUPS]],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return [
'type' => 'object',
'properties' => [
'settings' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'key' => ['type' => 'string'],
'group' => ['type' => 'string'],
'type' => ['type' => 'string'],
'value' => ['type' => ['string', 'integer']],
'description' => ['type' => 'string'],
],
'required' => ['key', 'group', 'type', 'value', 'description'],
],
],
],
'required' => ['settings'],
];
}
public function annotations(): array
{
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
return current_user_can('manage_options')
? true
: Errors::forbidden('Keine Berechtigung für WordPress-Einstellungen (manage_options).');
}
public function execute(array $input): mixed
{
$group = isset($input['group']) ? (string) $input['group'] : '';
$settings = [];
foreach (SettingsWhitelist::SETTINGS as $key => [$settingGroup, $type, $description]) {
if ($group !== '' && $settingGroup !== $group) {
continue;
}
$settings[] = [
'key' => $key,
'group' => $settingGroup,
'type' => $type,
'value' => $this->whitelist->current($key),
'description' => $description,
];
}
return ['settings' => $settings];
}
}
src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Settings;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\JsonMap;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use WP_Error;
final class WpSettingsUpdateAbility implements Ability
{
public function __construct(private SettingsWhitelist $whitelist, private ChangeLog $log)
{
}
public function name(): string
{
return 'lit/wp-settings-update';
}
public function label(): string
{
return 'WordPress-Einstellungen ändern';
}
public function description(): string
{
return 'Ändert freigegebene WordPress-Einstellungen (Schlüssel siehe lit-wp-settings-get). Alle Werte '
. 'werden vorher geprüft – bei einem Fehler wird nichts gespeichert. previous enthält die alten Werte. '
. 'Gesperrt: admin_email, users_can_register, default_role, siteurl, home.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'settings' => [
'type' => 'object',
'minProperties' => 1,
'description' => 'Schlüssel → neuer Wert, z. B. {"blogname": "Praxis Muster"}.',
],
],
'required' => ['settings'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema(['previous' => ['type' => 'object']], ['previous']);
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
return current_user_can('manage_options')
? true
: Errors::forbidden('Keine Berechtigung für WordPress-Einstellungen (manage_options).');
}
public function execute(array $input): mixed
{
$settings = is_array($input['settings'] ?? null) ? $input['settings'] : [];
if ($settings === []) {
return Errors::invalidInput('Keine Einstellungen angegeben.', ['settings' => 'mindestens ein Schlüssel']);
}
$errors = [];
foreach ($settings as $key => $value) {
$error = $this->whitelist->validate((string) $key, $value, $settings);
if ($error !== null) {
$errors[(string) $key] = $error;
}
}
if ($errors !== []) {
return Errors::invalidInput(
'Einstellungen ungültig; nichts wurde gespeichert.',
$errors,
'Werte gemäß fields korrigieren; aktuelle Werte über lit-wp-settings-get.'
);
}
$previous = [];
$changed = [];
foreach ($settings as $key => $value) {
$key = (string) $key;
$previous[$key] = $this->whitelist->current($key);
if ($previous[$key] === $value) {
continue;
}
if ($key === 'permalink_structure') {
$this->setPermalinkStructure((string) $value);
} else {
update_option($key, $this->whitelist->normalize($key, $value));
}
$changed[] = $key;
}
if ($changed === []) {
return ToolResult::ok('Keine Änderung – alle Werte waren bereits gesetzt.')
->with('previous', JsonMap::of($previous))
->toArray();
}
$summary = 'Einstellungen geändert: ' . implode(', ', $changed);
$logId = $this->log->record($this->name(), 'option', implode(',', $changed), $summary);
$result = ToolResult::ok($summary . '.');
foreach ($changed as $key) {
$result->changed($key, 'option', 'updated', SettingsWhitelist::SETTINGS[$key][2]);
}
return $result->logId($logId)->with('previous', JsonMap::of($previous))->toArray();
}
private function setPermalinkStructure(string $structure): void
{
global $wp_rewrite;
$wp_rewrite->set_permalink_structure($structure);
flush_rewrite_rules();
}
}
src/Integrations/WordPress/WordPressIntegration.php: Imports ergänzen
use LIT\MCP\Integrations\WordPress\Settings\SettingsWhitelist;
use LIT\MCP\Integrations\WordPress\Settings\WpSettingsGetAbility;
use LIT\MCP\Integrations\WordPress\Settings\WpSettingsUpdateAbility;
und abilities() durch die vollständige Endfassung ersetzen:
public function abilities(): array
{
$presenter = new PostPresenter($this->schema);
$writer = new PostWriter($this->schema, new FieldValidator(), new TermAssigner());
$cloner = new PostCloner();
$whitelist = new SettingsWhitelist();
return [
new ContentTypesAbility($this->schema),
new ContentQueryAbility($this->schema, $presenter),
new ContentGetAbility($this->schema, $presenter),
new ContentCreateAbility($this->schema, $writer, $this->log),
new ContentUpdateAbility($this->schema, $writer, $this->log),
new PostPublishAbility($this->schema, $this->log),
new PostUnpublishAbility($this->schema, $this->log),
new PostScheduleAbility($this->schema, $this->log),
new PostTrashAbility($this->schema, $this->log),
new PostDuplicateAbility($this->schema, $cloner, $this->log),
new PostReplaceWithCopyAbility($this->schema, $cloner, $this->log),
new RevisionsListAbility($this->schema),
new RevisionRestoreAbility($this->schema, $this->log),
new MenuListAbility(),
new MenuGetAbility(),
new MenuCreateAbility($this->log),
new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), $this->log),
new MenuAssignLocationAbility($this->log),
new WpSettingsGetAbility($whitelist),
new WpSettingsUpdateAbility($whitelist, $this->log),
];
}
- Step 4: Run – muss bestehen
Run: composer test && composer lint
Expected: PASS.
- Step 5: Commit
git add src/Integrations/WordPress tests/Unit/Integrations/WordPress
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: whitelisted WordPress settings tools
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
Task 16: Anleitungen und Ende-zu-Ende-Test über HTTP
Die Anleitungen content-types und workflows (Spec §9.2) beschreiben die neuen Tools für Claude; der Ende-zu-Ende-Test ruft jede Ability dieses Plans über den echten OAuth-Endpunkt auf. Da WordPress jede Ausgabe gegen das outputSchema prüft, ist das zugleich der automatische Output-Schema-Test aus Spec §11.4.
Files:
- Create:
docs/guides/content-types.md,docs/guides/workflows.md,tests/Integration/lib.sh,tests/Integration/wp-basis-smoke.sh - Modify:
docs/guides/overview.md(Abschnitt „Verfügbare Werkzeuge“ ersetzen)
Interfaces:
-
Consumes: alle Abilities der Tasks 6–15;
tests/Integration/mint-token.php,tests/Integration/pods-fixture.php. -
Produces:
tests/Integration/lib.shmitfail,pass,cleanup_app_passwords_for <login>,connect <login>(setztTOKEN,SESSION,INIT_RESPONSE),rpc <method> <params-json>,tool <name> <args-json>(gibtstructuredContentoder{"__error": "<text>"}aus),ability <lit/name> <params-json>(übermcp-adapter-execute-ability; gibt das Ergebnis oder{"__error": …}aus),error_code <json>(liefert z. B.rev_mismatch) – Etappe 1b nutzt dieselben Helfer. -
Step 1: Anleitungen
docs/guides/content-types.md:
# Inhalte, Felder und Status
## Inhaltstypen erkennen
- `lit-content-types` ohne Parameter listet alle Inhaltstypen (Seiten, Beiträge, eigene Typen) mit
Taxonomien, Anzahl und ob Divi dafür aktiv ist.
- `lit-content-types` mit `post_type` liefert zusätzlich alle Felder: Schlüssel, Typ, Pflicht, Auswahlwerte
(`choices`), Beziehungsziel (`related`) und Schreibstatus:
- `writable` – sicher beschreibbar (Pods-Feld oder registriertes Meta-Feld),
- `readonly` – nicht beschreibbar,
- `unknown` – vorhandener Meta-Schlüssel ohne bekannten Typ; Schreiben geht, der Wert wird unverändert
gespeichert (Warnung in der Antwort).
## Lesen und Suchen
- `lit-content-query`: Filter `status`, `search`, `taxonomy` (`{taxonomy, terms: [IDs oder Slugs]}`),
`field` (`{key, value, compare}`), `date_after`/`date_before` (JJJJ-MM-TT), `parent`; Blättern mit
`next_cursor` → `cursor`.
- `lit-content-get`: alle Daten eines Eintrags inkl. Feldern, Begriffen, Beitragsbild und `rev`.
Bei Divi-Seiten fehlt `content`; `hints` verweist auf `lit-divi-outline`.
## Anlegen und Ändern
- `lit-content-create` legt immer einen **Entwurf** an. Felder werden gegen das Schema geprüft; ein Fehler
bricht den ganzen Aufruf ab (`fields.<schlüssel>` im Fehler nennt die Ursache).
- `lit-content-update` ändert nur die übergebenen Werte, der Status bleibt. `rev` aus `lit-content-get`
mitgeben – hat jemand parallel gespeichert, kommt `rev_mismatch`: neu lesen, Änderung wiederholen.
- Begriffe: `terms: {"category": [4, "Neuer Begriff"]}` ersetzt die Zuordnung dieser Taxonomie (IDs oder
Namen; unbekannte Namen werden angelegt, falls erlaubt).
- Felder leeren: Wert `null`. Pflichtfelder können nicht geleert werden.
- Beziehungsfelder erwarten Eintrags-IDs, Datei-/Bildfelder Anhang-IDs aus der Mediathek.
## Status
- Veröffentlichen, Zurückziehen und Planen sind eigene Werkzeuge (`lit-post-publish`, `lit-post-unpublish`,
`lit-post-schedule` mit `date` = „JJJJ-MM-TT HH:MM“ in der Zeitzone der Website). Nur auf ausdrücklichen
Wunsch des Nutzers verwenden.
- `lit-post-trash` legt in den Papierkorb (nie endgültig) und braucht `confirm: true` nach Rückfrage.
## Sicher überarbeiten
1. `lit-post-duplicate` → Entwurfskopie.
2. Kopie mit `lit-content-update` (oder den Divi-Tools) bearbeiten und dem Nutzer die Vorschau zeigen.
3. `lit-post-replace-with-copy` mit `confirm: true` übernimmt die Kopie ins Original (ID, URL, Status und
Menüeinträge bleiben; vorher entsteht eine Revision).
Jede Änderung liefert `revision_id` (Stand vorher) und `log_id`. Rückgängig: `lit-revisions-list` →
`lit-revision-restore` mit `confirm: true`. Revisionen umfassen Titel, Inhalt und Auszug – Felder und
Begriffe nicht.
docs/guides/workflows.md:
# Abläufe
## Neue Website aufsetzen
1. `lit-site-status` und `lit-content-types` lesen.
2. Grundeinstellungen: `lit-wp-settings-get`, dann `lit-wp-settings-update` (Titel, Untertitel, Sprache,
Zeitzone, Datumsformat, Permalinks `/%postname%/`). `previous` in der Antwort enthält die alten Werte.
3. Seiten als Entwürfe anlegen (`lit-content-create`), Hierarchie über `parent`.
4. Startseite: Seite veröffentlichen (nach Rückfrage), dann
`{"settings": {"page_on_front": <ID>, "show_on_front": "page"}}`.
5. Menü: `lit-menu-create` (optional mit `location` aus `lit-menu-list`), Einträge mit `lit-menu-set-items`.
6. Veröffentlichen erst, wenn der Nutzer es ausdrücklich möchte.
## Menüs pflegen
- `lit-menu-get` liefert den Baum. Für größere Umbauten den Baum verändern und vollständig als `items` an
`lit-menu-set-items` senden (bestehende Einträge mit `id`, neue ohne `id`).
- Für kleine Änderungen `operations`: `{op: "add", item, parent_id?, position?}`,
`{op: "move", id, parent_id?, position?}`, `{op: "remove", id}`.
- Fallen Einträge weg, verlangt das Tool `confirm: true`; `previous_items` erlaubt das Zurückbauen.
## Einträge eines eigenen Inhaltstyps (Pods)
1. `lit-content-types` mit `post_type` → Pflichtfelder, Auswahlwerte, Beziehungsziele.
2. `lit-content-create` mit allen Pflichtfeldern in `fields`.
3. Beziehungen über IDs (vorher mit `lit-content-query` suchen).
docs/guides/overview.md: den Abschnitt ab ## Verfügbare Werkzeuge (Etappe 0) bis zum Dateiende ersetzen durch:
## Werkzeuge
Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`,
`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`.
Über `mcp-adapter-discover-abilities` / `mcp-adapter-execute-ability` (Ability-Namen `lit/…`):
Status (`lit/post-unpublish`, `lit/post-schedule`, `lit/post-trash`), Kopien (`lit/post-duplicate`,
`lit/post-replace-with-copy`), Revisionen (`lit/revisions-list`, `lit/revision-restore`), Menüs
(`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`),
Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`).
Weitere Themen: `lit-guide` mit `topic` = `content-types` oder `workflows`.
## Rückmeldungen
- Erfolg: `ok`, `summary`, `changed`, `warnings`, `revision_id` (Stand vorher), `log_id`, URLs.
- Fehler: JSON mit `code`, `message`, `hint` (und ggf. `fields`). Dem `hint` folgen.
- `confirm_required`: Nutzer fragen, dann mit `confirm: true` wiederholen.
- Step 2: Gemeinsame Shell-Helfer
tests/Integration/lib.sh:
#!/usr/bin/env bash
# Shared helpers for the MCP integration scripts. Source it from a script that
# runs INSIDE the DDEV web container with /var/www/html as working directory.
# Requires jq and curl (both present in the DDEV web image).
PLUGIN=wp-content/plugins/WP-LIT-MCP
ENDPOINT=http://localhost/wp-json/mcp/mcp-oauth-server
TOKEN=""
SESSION=""
INIT_RESPONSE=""
REQUEST_ID=100
fail() { echo "FAIL: $*"; exit 1; }
pass() { echo "ok - $*"; }
# Removes every "lit-mcp-integration-test" application password of a user.
cleanup_app_passwords_for() { # $1 = user_login
local login="$1"
[ -n "$login" ] || return 0
local escaped=${login//\\/\\\\}
escaped=${escaped//\'/\\\'}
wp eval "
\$u = get_user_by('login', '${escaped}');
if (\$u) {
foreach (WP_Application_Passwords::get_user_application_passwords(\$u->ID) as \$pw) {
if ((\$pw['name'] ?? '') === 'lit-mcp-integration-test') {
WP_Application_Passwords::delete_application_password(\$u->ID, \$pw['uuid']);
}
}
}
" >/dev/null 2>&1 || true
}
# Mints a token for the user and opens an MCP session. Sets TOKEN, SESSION and
# INIT_RESPONSE (raw initialize response incl. headers).
connect() { # $1 = user_login
TOKEN=$(wp eval-file "$PLUGIN/tests/Integration/mint-token.php" "$1")
local init
init=$(curl -s --max-time 20 -i -X POST "$ENDPOINT" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}')
SESSION=$(echo "$init" | grep -i '^mcp-session-id:' | awk '{print $2}' | tr -d '\r' || true)
INIT_RESPONSE="$init"
echo "$init" | grep -q '"protocolVersion"' || fail "initialize failed: $init"
}
# Raw JSON-RPC call; prints the response body.
rpc() { # $1 = method, $2 = params JSON
REQUEST_ID=$((REQUEST_ID + 1))
local body
body=$(jq -cn --arg m "$1" --argjson p "$2" --argjson id "$REQUEST_ID" '{jsonrpc: "2.0", id: $id, method: $m, params: $p}')
local session_header=()
[ -n "$SESSION" ] && session_header=(-H "Mcp-Session-Id: $SESSION")
curl -s --max-time 60 -X POST "$ENDPOINT" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
"${session_header[@]}" -d "$body"
}
# Calls a tool; prints structuredContent, or {"__error": "<text>"} for isError results.
tool() { # $1 = tool name, $2 = arguments JSON
local response
response=$(rpc tools/call "$(jq -cn --arg n "$1" --argjson a "$2" '{name: $n, arguments: $a}')")
echo "$response" | jq -e '.result' >/dev/null 2>&1 || fail "$1: protocol error: $response"
if [ "$(echo "$response" | jq -r '.result.isError')" = "true" ]; then
echo "$response" | jq -c '{__error: .result.content[0].text}'
else
echo "$response" | jq -c '.result.structuredContent'
fi
}
# Runs an indirect ability through mcp-adapter-execute-ability; prints its result or {"__error": …}.
ability() { # $1 = ability name (lit/…), $2 = parameters JSON
local out
out=$(tool mcp-adapter-execute-ability "$(jq -cn --arg n "$1" --argjson p "$2" '{ability_name: $n, parameters: $p}')")
if echo "$out" | jq -e 'has("__error")' >/dev/null; then
echo "$out"
elif [ "$(echo "$out" | jq -r '.success')" = "true" ]; then
echo "$out" | jq -c '.data'
else
echo "$out" | jq -c '{__error: .error}'
fi
}
# Prints the lit error code (e.g. rev_mismatch) of a tool()/ability() result, or "".
error_code() { # $1 = result JSON
echo "$1" | jq -r '(.__error // "") | (try fromjson catch {}) | (.code // "")'
}
- Step 3: Ende-zu-Ende-Skript
tests/Integration/wp-basis-smoke.sh:
#!/usr/bin/env bash
# End-to-end checks of the Etappe-1a tools (content, status, copies, revisions,
# menus, WordPress settings, change log) through the OAuth-protected endpoint.
# Runs INSIDE the DDEV web container:
# cd /home/janniclas/Projekte/WordPress && timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/wp-basis-smoke.sh
# Temporarily activates the installed Pods plugin (restored afterwards) and
# restores every setting/menu location it changes.
set -euo pipefail
cd /var/www/html
# shellcheck source=lib.sh
source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh
ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1)
PODS_WAS_ACTIVE=no
wp plugin is-active pods && PODS_WAS_ACTIVE=yes
ORIGINAL_DESCRIPTION=$(wp option get blogdescription)
ORIGINAL_LOCATIONS=$(wp eval 'echo wp_json_encode(get_nav_menu_locations());')
CREATED_POSTS=()
MENU_ID=""
cleanup() {
for id in "${CREATED_POSTS[@]}"; do
wp post delete "$id" --force >/dev/null 2>&1 || true
done
[ -n "$MENU_ID" ] && wp menu delete "$MENU_ID" >/dev/null 2>&1 || true
wp option update blogdescription "$ORIGINAL_DESCRIPTION" >/dev/null 2>&1 || true
LIT_LOCATIONS="$ORIGINAL_LOCATIONS" wp eval \
'set_theme_mod("nav_menu_locations", (array) json_decode(getenv("LIT_LOCATIONS"), true));' >/dev/null 2>&1 || true
wp eval-file "$PLUGIN/tests/Integration/pods-fixture.php" delete >/dev/null 2>&1 || true
[ "$PODS_WAS_ACTIVE" = "no" ] && wp plugin deactivate pods >/dev/null 2>&1 || true
cleanup_app_passwords_for "$ADMIN_LOGIN"
}
trap cleanup EXIT
wp plugin activate pods >/dev/null
wp eval-file "$PLUGIN/tests/Integration/pods-fixture.php" create >/dev/null
connect "$ADMIN_LOGIN"
# 1. Tool list: direct tools visible with annotations, the rest only via meta-tools
LIST=$(rpc tools/list '{}')
for t in lit-content-types lit-content-query lit-content-get lit-content-create lit-content-update lit-post-publish; do
echo "$LIST" | jq -e --arg t "$t" '.result.tools[] | select(.name == $t)' >/dev/null || fail "tools/list lacks $t"
done
echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-post-trash")' >/dev/null \
&& fail "lit-post-trash must only be reachable via execute-ability"
echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-content-get") | .annotations.readOnlyHint == true' >/dev/null \
|| fail "lit-content-get lacks readOnlyHint"
pass "tools/list: direct content tools with annotations, indirect status tools"
# 2. Schema detection incl. Pods
TYPES=$(tool lit-content-types '{}')
echo "$TYPES" | jq -e '.post_types[] | select(.name == "page")' >/dev/null || fail "content-types lacks page: $TYPES"
PERSON_TYPE=$(tool lit-content-types '{"post_type":"lit_person"}')
echo "$PERSON_TYPE" | jq -e '.post_types[0].fields[] | select(.key == "role" and .required == true and .source == "pods")' >/dev/null \
|| fail "Pods field role missing: $PERSON_TYPE"
echo "$PERSON_TYPE" | jq -e '.post_types[0].fields[] | select(.key == "level" and (.choices | length) == 2)' >/dev/null \
|| fail "Pods choices missing: $PERSON_TYPE"
pass "content-types lists post types and Pods fields"
# 3. Create, read, update with rev lock
CREATED=$(tool lit-content-create '{"post_type":"page","title":"LIT Smoke Seite","content":"<p>Hallo</p>"}')
PAGE_ID=$(echo "$CREATED" | jq -r '.changed[0].id // empty')
[ -n "$PAGE_ID" ] || fail "content-create: $CREATED"
CREATED_POSTS+=("$PAGE_ID")
[ "$(wp post get "$PAGE_ID" --field=post_status)" = "draft" ] || fail "new page is not a draft"
REV=$(tool lit-content-get "{\"post_id\":$PAGE_ID}" | jq -r '.rev')
STALE=$(tool lit-content-update "{\"post_id\":$PAGE_ID,\"rev\":\"000000000000\",\"title\":\"x\"}")
[ "$(error_code "$STALE")" = "rev_mismatch" ] || fail "stale rev accepted: $STALE"
UPDATED=$(tool lit-content-update "{\"post_id\":$PAGE_ID,\"rev\":\"$REV\",\"content\":\"<p>Neu</p>\"}")
echo "$UPDATED" | jq -e '.ok == true and .log_id != null' >/dev/null || fail "content-update: $UPDATED"
[ "$(wp post get "$PAGE_ID" --field=post_status)" = "draft" ] || fail "update changed the status"
pass "content create/get/update, rev lock, status unchanged"
# 4. Pods entries: required field enforced, values written through Pods
MISSING=$(tool lit-content-create '{"post_type":"lit_person","title":"Ohne Funktion"}')
[ "$(error_code "$MISSING")" = "validation_failed" ] || fail "missing required Pods field accepted: $MISSING"
PERSON=$(tool lit-content-create '{"post_type":"lit_person","title":"Anna","fields":{"role":"Leitung","level":"senior"}}')
PERSON_ID=$(echo "$PERSON" | jq -r '.changed[0].id // empty')
[ -n "$PERSON_ID" ] || fail "Pods entry: $PERSON"
CREATED_POSTS+=("$PERSON_ID")
tool lit-content-get "{\"post_id\":$PERSON_ID}" | jq -e '.fields.role == "Leitung" and .fields.level == "senior"' >/dev/null \
|| fail "Pods values not readable"
pass "Pods entry with validated fields"
# 5. Status tools
tool lit-post-publish "{\"post_id\":$PAGE_ID}" | jq -e '.changed[0].action == "published"' >/dev/null || fail "publish"
[ "$(wp post get "$PAGE_ID" --field=post_status)" = "publish" ] || fail "page not published"
ability lit/post-unpublish "{\"post_id\":$PAGE_ID}" | jq -e '.ok == true' >/dev/null || fail "unpublish"
[ "$(wp post get "$PAGE_ID" --field=post_status)" = "draft" ] || fail "page not back to draft"
FUTURE=$(date -d '+2 days' '+%Y-%m-%d 10:00')
ability lit/post-schedule "{\"post_id\":$PAGE_ID,\"date\":\"$FUTURE\"}" | jq -e '.ok == true' >/dev/null || fail "schedule"
[ "$(wp post get "$PAGE_ID" --field=post_status)" = "future" ] || fail "page not scheduled"
PAST=$(ability lit/post-schedule "{\"post_id\":$PAGE_ID,\"date\":\"2020-01-01 10:00\"}")
[ "$(error_code "$PAST")" = "validation_failed" ] || fail "past schedule accepted: $PAST"
tool lit-post-publish "{\"post_id\":$PAGE_ID}" >/dev/null
[ "$(wp post get "$PAGE_ID" --field=post_status)" = "publish" ] || fail "scheduled page did not publish now"
pass "publish/unpublish/schedule, past dates rejected"
# 6. Copy and replace
COPY=$(ability lit/post-duplicate "{\"post_id\":$PAGE_ID}")
COPY_ID=$(echo "$COPY" | jq -r '.changed[0].id // empty')
[ -n "$COPY_ID" ] || fail "duplicate: $COPY"
CREATED_POSTS+=("$COPY_ID")
tool lit-content-update "{\"post_id\":$COPY_ID,\"content\":\"<p>Aus Kopie</p>\"}" >/dev/null
NOCONFIRM=$(ability lit/post-replace-with-copy "{\"original_id\":$PAGE_ID,\"copy_id\":$COPY_ID}")
[ "$(error_code "$NOCONFIRM")" = "confirm_required" ] || fail "replace without confirm: $NOCONFIRM"
ability lit/post-replace-with-copy "{\"original_id\":$PAGE_ID,\"copy_id\":$COPY_ID,\"confirm\":true}" \
| jq -e '.revision_id != null' >/dev/null || fail "replace-with-copy"
[ "$(wp post get "$PAGE_ID" --field=post_content)" = "<p>Aus Kopie</p>" ] || fail "original not replaced"
[ "$(wp post get "$PAGE_ID" --field=post_status)" = "publish" ] || fail "replace changed the original's status"
[ "$(wp post get "$COPY_ID" --field=post_status)" = "trash" ] || fail "copy not trashed"
pass "duplicate and replace-with-copy (status kept, copy trashed)"
# 7. Revisions
REVISIONS=$(ability lit/revisions-list "{\"post_id\":$PAGE_ID}")
OLDEST=$(echo "$REVISIONS" | jq -r '.items[-1].id // empty')
[ -n "$OLDEST" ] || fail "revisions-list: $REVISIONS"
ability lit/revision-restore "{\"revision_id\":$OLDEST,\"confirm\":true}" | jq -e '.ok == true' >/dev/null \
|| fail "revision-restore"
pass "revisions list and restore"
# 8. Trash
ability lit/post-trash "{\"post_id\":$PERSON_ID,\"confirm\":true}" | jq -e '.changed[0].action == "trashed"' >/dev/null \
|| fail "post-trash"
[ "$(wp post get "$PERSON_ID" --field=post_status)" = "trash" ] || fail "entry not in trash"
pass "post-trash moves to trash"
# 9. Menus
MENU=$(ability lit/menu-create '{"name":"LIT Smoke Menü"}')
MENU_ID=$(echo "$MENU" | jq -r '.menu_id // empty')
[ -n "$MENU_ID" ] || fail "menu-create: $MENU"
ability lit/menu-set-items "{\"menu_id\":$MENU_ID,\"items\":[{\"type\":\"post\",\"object_id\":$PAGE_ID,\"children\":[{\"type\":\"custom\",\"url\":\"/kontakt\",\"title\":\"Kontakt\"}]}]}" \
| jq -e '.ok == true' >/dev/null || fail "menu-set-items (tree)"
TREE=$(ability lit/menu-get "{\"menu_id\":$MENU_ID}")
CHILD_ID=$(echo "$TREE" | jq -r '.items[0].children[0].id // empty')
[ "$(echo "$TREE" | jq -r '.items[0].children[0].title')" = "Kontakt" ] || fail "menu tree: $TREE"
REMOVE=$(ability lit/menu-set-items "{\"menu_id\":$MENU_ID,\"operations\":[{\"op\":\"remove\",\"id\":$CHILD_ID}]}")
[ "$(error_code "$REMOVE")" = "confirm_required" ] || fail "menu removal without confirm: $REMOVE"
ability lit/menu-set-items "{\"menu_id\":$MENU_ID,\"operations\":[{\"op\":\"remove\",\"id\":$CHILD_ID}],\"confirm\":true}" \
| jq -e '(.previous_items[0].children | length) == 1 and (.items[0].children | length) == 0' >/dev/null \
|| fail "menu removal with confirm"
LOCATION=$(ability lit/menu-list '{}' | jq -r '.locations[0].location // empty')
if [ -n "$LOCATION" ]; then
ability lit/menu-assign-location "{\"location\":\"$LOCATION\",\"menu_id\":$MENU_ID}" | jq -e '.ok == true' >/dev/null \
|| fail "menu-assign-location"
fi
pass "menu create, tree, operations with confirm, location"
# 10. WordPress settings (whitelist)
ability lit/wp-settings-get '{"group":"general"}' | jq -e '.settings[] | select(.key == "blogname")' >/dev/null \
|| fail "wp-settings-get"
BLOCKED=$(ability lit/wp-settings-update '{"settings":{"admin_email":"x@example.invalid"}}')
[ "$(error_code "$BLOCKED")" = "validation_failed" ] || fail "blocked setting accepted: $BLOCKED"
ability lit/wp-settings-update '{"settings":{"blogdescription":"LIT Smoke Test"}}' \
| jq -e '.previous.blogdescription != null' >/dev/null || fail "wp-settings-update"
[ "$(wp option get blogdescription)" = "LIT Smoke Test" ] || fail "blogdescription not updated"
pass "WordPress settings whitelist"
# 11. Change log with client name
PREFIX=$(wp db prefix)
ROWS=$(wp db query "SELECT COUNT(*) FROM ${PREFIX}lit_mcp_log WHERE ability = 'lit/content-create' AND client = 'lit-mcp-integration-test' AND created_at > (UTC_TIMESTAMP() - INTERVAL 10 MINUTE)" --skip-column-names)
[ "$ROWS" -ge 2 ] || fail "change log rows missing (found $ROWS)"
pass "change log records ability and client"
echo "ALL CHECKS PASSED"
- Step 4: Ende-zu-Ende-Test ausführen
Run:
cd /home/janniclas/Projekte/WordPress
timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/wp-basis-smoke.sh
timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/mcp-smoke.sh
timeout 120 ddev wp plugin is-active pods || echo "pods inactive (restored)"
Expected: beide Skripte ALL CHECKS PASSED; Pods danach wieder inaktiv. Schlägt ein Schritt mit ability_invalid_output fehl, passt die Ausgabe nicht zum outputSchema – Schema oder Ausgabe korrigieren und einen Unit-Test für den Fall ergänzen.
- Step 5: Unit-Tests und Lint
Run: cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test && composer lint
Expected: PASS.
- Step 6: Commit
git add docs/guides tests/Integration/lib.sh tests/Integration/wp-basis-smoke.sh
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "test: end-to-end checks for the WordPress basics; content and workflow guides
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
Self-Review (durchgeführt beim Schreiben)
- Spec-Abdeckung (Teil 1a): §6.1 Schema inkl. Pods → Tasks 4–6; §6.2 Content-Tools, Status, Kopie/Austausch, Papierkorb, Revisionen,
lit_mcp_after_save→ Tasks 8–12; §6.4 Menüs → Tasks 13–14; §6.5 Einstellungen → Task 15; §8.1 ToolResult → Task 1; §8.2 Fehler mit hint, alles oder nichts, Stapel → Tasks 1, 7, 9; §8.3 Protokolltabelle, Aufbewahrung, Cron → Task 3 (Ansicht/Leeren in 1b); §9.1 Instructions mit Inhaltstypen → Task 6; §9.2 Guidescontent-types,workflows→ Task 16; §11.3 Schemas, Annotations, Cursor, deterministische Tool-Reihenfolge → alle Ability-Tasks,WordPressIntegrationTest; §11.4 Output-Schema-Prüfung je Ability → Task 16 (WordPress validiert jede Ausgabe). Etappe-0-Rückstände: (e) Flag-Reset → Task 2, (f)Tested up to→ Task 1. Medien §6.3, Einstellungsseite §8.4, Expositions-Policy §4.3, Sichtbarkeit, Hinweise, Aufräumen, Doku → Plan 1b. - Bewusste Abweichungen/Präzisierungen: Divi-Kurzform in
lit-content-create(§6.2) kommt mit Etappe 2 (BlockTree fehlt noch); bis dahin nur Hinweis.revist für Nicht-Divi-Inhalte optional und nur inlit-content-updategeprüft. Fehler-JSON in der Nachricht, weil Adapter 0.5 nur den Text weitergibt. Menüeinträge werden wie im WordPress-Menü-Editor endgültig entfernt (kein Papierkorb fürnav_menu_item), aber nur mitconfirm: trueund mitprevious_items. - Typ-Konsistenz geprüft:
PostGuard::editable/postType/id,RevisionPoint::capture/latest,ChangeLog::record(…, ?int $revisionId),ToolResult::with()-Extras (original_id,menu_id,previous_menu_id,items,previous_items,previous) stimmen zwischen Interfaces-Blöcken, Code und Tests überein.