Files
WP-LIT-MCP/docs/superpowers/plans/2026-09-25-etappe-1a-wordpress-inhalte.md

403 KiB
Raw Permalink Blame History

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-MCP anlegen 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 lint und composer test mü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 in tests/Stubs/ (in tests/bootstrap.php eingebunden). Funktionen, die TestCase::setUp() per Functions\stubs() vorbelegt (wp_json_encode, is_wp_error, absint, wp_slash, wp_unslash, sanitize_text_field), nie mit Functions\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 über timeout 120 ddev wp ….
  • Ability-Namen lit/<bereich>-<aktion> → Tool-Namen lit-<bereich>-<aktion>. Annotations exakt: Lese-Tools readonly: true; destructive: true nur für Löschen, Ersetzen, Austausch (Papierkorb, Kopie-Austausch, Revision wiederherstellen, Menübaum ersetzen); idempotent wie 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 (type inkl. Typ-Listen, properties, required, items, enum, minimum, maximum, minLength, maxLength, pattern, minItems, maxItems, additionalProperties). WordPress prüft Eingaben vor execute() und Ausgaben danach – eine Ausgabe, die nicht zum outputSchema passt, wird zum Fehler.
  • Fachliche Fehler ausschließlich über LIT\MCP\Abilities\Errors (Code lit_<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: true verlangen confirm: true, sonst lit_confirm_required ohne jede Änderung.
  • Jede erfolgreiche Änderung schreibt einen Protokolleintrag (ChangeLog::record) und liefert ToolResult nach Spec §8.1; revision_id ist der Wiederherstellungspunkt vor der Änderung (oder null), rev der Hash des post_content danach (oder null bei Nicht-Beiträgen).
  • Listen mit Cursor-Paginierung: limit 1–100 (Standard 20), cursor rein, next_cursor (oder null) raus.
  • Keine Divi-Tools in dieser Etappe; Divi-Inhalte werden erkannt (_et_pb_use_builder = on oder <!-- wp:divi/ im Inhalt) und nur per Texthinweis auf lit-divi-outline verwiesen; post_content von Divi-Seiten wird nicht überschrieben.
  • WP-Einstellungen ausschließlich über die Whitelist aus Spec §6.5; admin_email, users_can_register, default_role, siteurl, home sind gesperrt.
  • Commits: git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit … mit den Trailern Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> und Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7.

Review Focus

  1. Veröffentlichte Seite wird per lit-content-update geändert: Status bleibt publish, vorher existiert ein Wiederherstellungspunkt (revision_id) → Test in Task 9 (wp_update_post ohne post_status, RevisionPoint vor dem Schreiben).
  2. Divi-Seite + content im Update: Rohtext-Überschreiben wird mit lit_unsupported abgelehnt, nichts geschrieben; Titel/Felder ohne content gehen weiter → Test in Task 9.
  3. Pods-Pflichtfeld fehlt beim Anlegen oder wird beim Update geleert, Auswahlwert ungültig, Beziehung auf falschen Typ: lit_validation_failed mit fields.<key>, kein Eintrag entsteht → Tests in Task 7 und Task 9.
  4. 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 future zu werden → Tests in Task 10.
  5. Menü-Gesamtbaum ohne die bestehenden Einträge (oder remove-Operation): ohne confirm: true → lit_confirm_required mit Liste der betroffenen Einträge, nichts geschrieben; Antwort enthält immer previous_items zum 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 in AbilityRegistrar, GuideAbility bleiben unverändert gültig).

  • Produces:

    • Errors::make(string $code, string $message, string $hint, int $status, array $details = []): WP_Error – Code lit_<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() – alle WP_Error.
    • ToolResult::ok(string $summary): ToolResult; Builder changed(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ängt recordRoute an rest_request_before_callbacks und clearRoute an rest_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 $response unverändert zurück.
    • RequestContext::clientName(): string – Name des Application Passwords, sonst client_id aus 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 wpdb für Unit-Tests (Eigenschaften prefix, posts, postmeta, usermeta, options, insert_id; echte prepare()/esc_like()), gemockt mit Mockery::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 = ''); Konstanten WRITABLE, 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 ($values key → Wert, null = leeren; $definitions key → FieldDefinition; wirft RuntimeException).
    • MetaFieldSource (abstrakt, implementiert read/write über die Meta-API), RegisteredMetaSource (id meta), SampledMetaSource (id sample, __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, ohne EXCLUDED, Filter lit_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 (id pods): __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 (related media), pick mit pick_object = custom-simple → choice (Auswahl aus pick_custom, Zeilen wert|Label), sonstiges pick → relation (related object_type/name aus get_related_object_type()/get_related_object_name()); Layout-Felder heading, html werden ausgelassen; unbekannte Typen → unknown/UNKNOWN. Integrationshelfer tests/Integration/pods-fixture.php create|delete legt den Pods-Post-Typ lit_person (Felder role Text Pflicht, level Auswahl junior|senior, mentor Beziehung auf lit_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); Name lit/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 Zeile Inhaltstypen: … (Felder und Taxonomien: lit-content-types).
    • Plugin::boot() registriert die WordPressIntegration; Plugin::contentSchema(): ContentSchema (privat) baut die Quellen: Pods (falls PodsFieldSource::isAvailable()), registrierte Meta, Stichprobe.
    • Hook-Vertrag für spätere Etappen: Action lit_mcp_after_save mit (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 und readonly-Felder sind Fehler; unknown-Felder erzeugen eine Warnung; null leert 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, falls edit_terms erlaubt); assign(int $postId, array $resolved): void (legt neue Begriffe an und ersetzt die Zuordnung je Taxonomie; wirft RuntimeException).
    • 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 ($cap ist 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 (Konstruktor array $fields) und WP_Term (Konstruktor array $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 (null bei 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); Name lit/content-query; Output {items: list<summary>, next_cursor: ?string}; direkt; readonly, idempotent.
    • ContentGetAbility: __construct(ContentSchema $schema, PostPresenter $presenter); Name lit/content-get; Input {post_id: int}; Output full; 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; Fehlerpfade fields.<key>, terms.<taxonomie>, featured_image, parent); postData(array $input): array (Eingabe → wp_insert_post-Spalten post_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; wirft RuntimeException); static inputProperties(): array (gemeinsame Input-Properties).
    • ContentCreateAbility: __construct(ContentSchema $schema, PostWriter $writer, ChangeLog $log); Name lit/content-create; Input post_type, title (Pflicht) + inputProperties(); kein status-Parameter; Output ToolResult::schema(); direkt; nicht idempotent.
    • ContentUpdateAbility: gleicher Konstruktor; Name lit/content-update; Input post_id (Pflicht), rev, title + inputProperties(); Output ToolResult::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), Output ToolResult::schema()):

    • PostPublishAbility – lit/post-publish, Input {post_id}; direkt; idempotent; Recht publish_post; setzt bei future/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; Recht publish_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; Recht delete_post; wp_trash_post() nur bei aktivem Papierkorb, sonst lit_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ßer SKIP_META, alle Begriffe, Markierung _lit_mcp_copy_of; wirft RuntimeException); replace(WP_Post $original, WP_Post $copy): void (Titel ohne „ (Kopie)“, Inhalt, Auszug; Meta des Originals außer SKIP_META gelö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-Extra original_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 per lit/post-duplicate aus 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_id im 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> mit Node = {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 (ohne children); 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-Extra menu_id; indirekt; nicht idempotent.
    • MenuAssignLocationAbility(ChangeLog $log) – lit/menu-assign-location, Input {location, menu_id} (0 entfernt); Ergebnis-Extra previous_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-Helfer MenuTreeTest::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} (wirft RuntimeException).
    • MenuSetItemsAbility: __construct(MenuWriter $writer, MenuOperations $operations, ChangeLog $log); lit/menu-set-items, Input {menu_id, items? | operations?, confirm?}; Ergebnis-Extras items (Baum nachher), previous_items (Baum vorher); indirekt; destructive: true, nicht idempotent.
  • 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 (WPLANG leer → en_US; Zahlenfelder als int); validate(string $key, mixed $value, array $pending): ?string (null = gültig; $pending = alle Werte dieses Aufrufs, für Abhängigkeiten show_on_front/page_on_front/page_for_posts); normalize(string $key, mixed $value): mixed (WPLANG en_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-Extra previous (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.sh mit fail, pass, cleanup_app_passwords_for <login>, connect <login> (setzt TOKEN, SESSION, INIT_RESPONSE), rpc <method> <params-json>, tool <name> <args-json> (gibt structuredContent oder {"__error": "<text>"} aus), ability <lit/name> <params-json> (über mcp-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 Guides content-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. rev ist für Nicht-Divi-Inhalte optional und nur in lit-content-update geprü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ür nav_menu_item), aber nur mit confirm: true und mit previous_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.