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

10906 lines
403 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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**
```bash
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
<?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
<?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
<?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**
```bash
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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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:
```php
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:
```php
$changeLog = new ChangeLog();
(new ChangeLogInstaller($changeLog))->maybeInstall();
(new ChangeLogCleanup($changeLog, new Limits()))->register();
```
und oben die Imports ergänzen:
```php
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:
```bash
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**
```bash
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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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**
```bash
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
<?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
<?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
<?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:
```bash
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**
```bash
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
<?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
<?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:
```php
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
<?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
<?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
<?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
<?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:
```bash
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**
```bash
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
<?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
<?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:
```php
require_once __DIR__ . '/Stubs/WpPost.php';
require_once __DIR__ . '/Stubs/WpTerm.php';
```
- [ ] **Step 2: Failing Tests**
`tests/Unit/Integrations/WordPress/Schema/FieldValidatorTest.php`:
```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
<?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
<?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
<?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
<?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
<?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
<?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
<?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**
```bash
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
<?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
<?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
<?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:
```php
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
<?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
<?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
<?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
```php
use LIT\MCP\Integrations\WordPress\Content\ContentGetAbility;
use LIT\MCP\Integrations\WordPress\Content\ContentQueryAbility;
use LIT\MCP\Integrations\WordPress\Content\PostPresenter;
```
und `abilities()` ersetzen:
```php
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**
```bash
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
<?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
<?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
<?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:
```php
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
<?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
<?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
<?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
```php
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:
```php
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**
```bash
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
<?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
<?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
<?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
<?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:
```php
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
<?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
<?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
<?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
<?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
```php
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:
```php
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**
```bash
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 „<Titel> (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
<?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
<?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
<?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:
```php
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
<?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
<?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
<?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
```php
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:
```php
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**
```bash
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
<?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
<?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:
```php
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
<?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
<?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
```php
use LIT\MCP\Integrations\WordPress\Revisions\RevisionRestoreAbility;
use LIT\MCP\Integrations\WordPress\Revisions\RevisionsListAbility;
```
und in `abilities()` nach `new PostReplaceWithCopyAbility(...)` einfügen:
```php
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**
```bash
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
<?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
<?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
<?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
<?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()`):
```php
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
<?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
<?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
<?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
<?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
<?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
<?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
```php
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:
```php
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**
```bash
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
<?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
<?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
<?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:
```php
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
<?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
<?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
<?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
```php
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:
```php
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**
```bash
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
<?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
<?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:
```php
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
<?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
<?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
<?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
```php
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:
```php
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**
```bash
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`:
```markdown
# 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`:
```markdown
# 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:
```markdown
## 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`:
```bash
#!/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`:
```bash
#!/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:
```bash
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**
```bash
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.