From 5434cb6767adf9accf942e9a47fd92140bdc175c Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 25 Sep 2026 21:21:18 +0200 Subject: [PATCH] docs: implementation plans for stage 1 (1a content/menus/settings, 1b media/admin) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- .../2026-09-25-etappe-1a-wordpress-inhalte.md | 10905 ++++++++++++++++ .../2026-09-25-etappe-1b-medien-admin.md | 6082 +++++++++ 2 files changed, 16987 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-25-etappe-1a-wordpress-inhalte.md create mode 100644 docs/superpowers/plans/2026-09-25-etappe-1b-medien-admin.md diff --git a/docs/superpowers/plans/2026-09-25-etappe-1a-wordpress-inhalte.md b/docs/superpowers/plans/2026-09-25-etappe-1a-wordpress-inhalte.md new file mode 100644 index 0000000..2ffef8a --- /dev/null +++ b/docs/superpowers/plans/2026-09-25-etappe-1a-wordpress-inhalte.md @@ -0,0 +1,10905 @@ +# 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/.sh`; WP-CLI über `timeout 120 ddev wp …`. +- Ability-Namen `lit/-` → Tool-Namen `lit--`. 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_`, 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 `']); + + $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 y']))); + self::assertFalse(PostPresenter::usesDivi(new WP_Post(['post_content' => '']))); + } +} +``` + +`tests/Unit/Integrations/WordPress/Content/ContentQueryAbilityTest.php`: +```php +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 + 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 + */ + 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 */ + 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, '', + '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'] === '' + && $data['post_parent'] === 2 + && $data['menu_order'] === 3 + && $data['post_author'] === 4); + } +} +``` + +`tests/Unit/Integrations/WordPress/Status/PostDuplicateAbilityTest.php`: +```php + 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 +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 + (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 + '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 $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 +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 $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) +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 +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' => '

Neu

', 'post_author' => '2']), + 811 => new WP_Post(['ID' => 811, 'post_title' => 'Team', 'post_content' => '

Auto

', 'post_author' => '2']), + 810 => new WP_Post(['ID' => 810, 'post_title' => 'Team', 'post_content' => '

Alt

', '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 +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 + '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 */ + 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 + '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 $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) +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 $items): list` mit `Node = {id: int, title: string, type: 'post'|'taxonomy'|'custom', object: string, object_id: int, url: string, children: list}` (Einträge mit fehlendem Elternteil landen auf oberster Ebene); `node(object $item): array` (ohne `children`); `nodeSchema(): array`. + - `MenuLocations` (final, statisch): `registered(): array`, `assigned(): array`, `of(int $menuId): list`, `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}`; 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 + $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 +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 +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 +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 + $items Sorted by menu_order (wp_get_nav_menu_items default). + * @return list> + */ + 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 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> $nodes + * @param array> $children + * @return list> + */ + 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 + Location slug => description. */ + public static function registered(): array + { + return array_map('strval', (array) get_registered_nav_menus()); + } + + /** @return array Location slug => menu ID. */ + public static function assigned(): array + { + $locations = get_nav_menu_locations(); + + return is_array($locations) ? array_map('intval', $locations) : []; + } + + /** @return list */ + 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 + '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 + '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 + '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 + '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) +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 $tree, list $operations): list|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 $tree, array $current): array{errors: array, removals: list, warnings: list}` (schreibt nichts); `apply(int $menuId, list $tree, array $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 +> 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 + */ + 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 +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 +> $tree Current tree (MenuTree::build()). + * @param list $operations + * @return list>|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> $tree + * @param array> $nodes + * @param array> $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> $nodes + * @param array> $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 $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> $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> $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> $nodes + * @param array> $children + * @return list> + */ + 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 + $tree + * @param array $current Current items by ID. + * @return array{errors: array, removals: list, 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> $tree Validated by plan(). + * @param array $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 $current + * @param array $seen + * @param array $errors + * @param list $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 $node + * @param array $current + * @param array $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 $node + * @param list $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> $nodes + * @param array $current + * @param array $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 */ + 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 $node + * @return array + */ + 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 + '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) +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 +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 +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 + [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 $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 $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 $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 + '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 + '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) +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 `, `connect ` (setzt `TOKEN`, `SESSION`, `INIT_RESPONSE`), `rpc `, `tool ` (gibt `structuredContent` oder `{"__error": ""}` aus), `ability ` (über `mcp-adapter-execute-ability`; gibt das Ergebnis oder `{"__error": …}` aus), `error_code ` (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.` 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": , "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": ""} 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":"

Hallo

"}') +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\":\"

Neu

\"}") +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\":\"

Aus Kopie

\"}" >/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)" = "

Aus Kopie

" ] || 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) +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. diff --git a/docs/superpowers/plans/2026-09-25-etappe-1b-medien-admin.md b/docs/superpowers/plans/2026-09-25-etappe-1b-medien-admin.md new file mode 100644 index 0000000..566aae6 --- /dev/null +++ b/docs/superpowers/plans/2026-09-25-etappe-1b-medien-admin.md @@ -0,0 +1,6082 @@ +# Etappe 1b – WordPress-Basis: Medien, Einstellungsseite, Exposition, Aufräumen – 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 verwaltet die Mediathek (suchen, ansehen inkl. Vorschaubild, Upload per URL und Datei, Download, Metadaten im Stapel, Löschen mit Verwendungsprüfung); Administratoren steuern LIT MCP über „Einstellungen → LIT MCP“ (Verbindung, Zugriff inkl. Widerruf, Tools direkt/indirekt/deaktiviert, Protokoll, Limits); nicht freigegebene Benutzer sehen keine Tools; Deaktivieren/Löschen räumt die eigenen Daten auf. + +**Architecture:** Neue Integration `MediaIntegration` mit `MediaUsageFinder` (Beitragsbilder, Inhalte inkl. Divi-Attribute, Datei-/Bildfelder, Website-Icon) und `MediaImporter` (SSRF-geschützter Download, MIME-Whitelist, Größenlimit). `ExposurePolicy` ersetzt `Ability::isDirect()` als Entscheidung (bleibt Standard) für `AbilityRegistrar` und `ServerRegistrar`. `VisibilityFilter` nutzt die Adapter-Filter `mcp_adapter_tools_list` und `mcp_adapter_initialize_response`. Die Einstellungsseite besteht aus einer `SettingsPage` mit `AdminTab`-Implementierungen (Settings API, `WP_List_Table`, kein JS-Framework). `uninstall.php` und `Lifecycle::deactivate()` räumen nur eigene Daten auf. + +**Tech Stack:** PHP ≥ 8.1, WordPress 7.0 (Abilities API, Settings API, `WP_List_Table`, Site Health), `wordpress/mcp-adapter` 0.5.0, `wp-media/mcp-oauth` 1.1.2, PHPUnit 9.6 + Brain Monkey + Mockery, phpcs, DDEV. + +**Spec:** `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` (insb. §4.3, §4.5, §6.3, §8.1–8.4, §10, §11.3, §12 Etappe 1) + +**Voraussetzung:** Plan `docs/superpowers/plans/2026-09-25-etappe-1a-wordpress-inhalte.md` ist vollständig umgesetzt (u. a. `Errors`, `ToolResult`, `Cursor`, `JsonMap`, `ChangeLog`, `Limits`, `Lifecycle`, `ContentSchema`, `PostGuard`, `WordPressIntegration`, `tests/Integration/lib.sh`, Stubs `WP_Post`, `WP_Term`, `wpdb`). + +## Global Constraints + +- Dateien ausschließlich im Plugin-Verzeichnis `/home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP` anlegen oder ändern. Keine neuen Plugins installieren; Pods darf für Integrationstests aktiviert und muss danach wieder deaktiviert werden. +- PHP ≥ 8.1, WordPress ≥ 6.9 (getestet mit 7.0.3), Plugin-Header und `readme.txt` mit `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` nach jedem Task sauber. +- Unit-Tests über `LIT\MCP\Tests\Unit\TestCase` (Brain Monkey + Mockery); die dort per `Functions\stubs()` vorbelegten Funktionen (`wp_json_encode`, `is_wp_error`, `absint`, `wp_slash`, `wp_unslash`, `sanitize_text_field`) nie mit `Functions\expect()` prüfen. Admin-Ausgaben mit `Functions\stubEscapeFunctions()` und `Functions\stubTranslationFunctions()` testen. +- Integrationstests im DDEV-Container: `cd /home/janniclas/Projekte/WordPress && timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/.sh`. +- Ability-Namen `lit/-`; Annotations exakt (`readonly` für Lese-Tools, `destructive` nur für `lit/media-delete`); Input-/Output-Schemas als JSON Schema 2020-12 im von `rest_validate_value_from_schema()` geprüften Umfang; Tools mit Bild- oder Ressourcen-Ergebnis (`lit/media-preview`, `lit/media-download`) haben **kein** `outputSchema`. +- Fachliche Fehler nur über `Errors` (`lit_*`, JSON mit `hint`); Schreib-Tools liefern `ToolResult` und schreiben einen Protokolleintrag; Stapel-Tools melden das Ergebnis je Eintrag. +- Medien: Import nur über `download_url()` (nutzt `wp_safe_remote_get`, SSRF-Schutz) nach Vorprüfung mit `wp_http_validate_url()`; MIME-Whitelist (Filter `lit_mcp_upload_mimes`); Größenlimit aus `Limits` (Standard 5 MB); `lit/media-delete` nur mit `confirm: true` und bei Verwendung nur mit `force: true`. +- Einstellungsseite „Einstellungen → LIT MCP“ (`options-general.php?page=lit-mcp`), Tabs Verbindung, Zugriff, Tools, Protokoll, Limits; Settings API, `WP_List_Table`, kein JS-Framework; alle Formulare mit Nonce und `manage_options`. Für das Divi-Code-Modul gibt es keine Einstellung. +- Aufräumen: nur eigene Daten (Optionen `lit_mcp_*`, Tabelle `{prefix}lit_mcp_log`, Cron `lit_mcp_log_cleanup`, Transient `lit_mcp_endpoint_check`); Daten von mcp-oauth (`mcp_jwt_secret`, `wpmedia_mcp_oauth_rewrite_version`, Benutzer-Meta `mcp_refresh_jti_*`, Anwendungspasswörter) bleiben unangetastet. +- 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) ` und `Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7`. + +## Review Focus + +1. **Medium wird noch verwendet** (Beitragsbild, Divi-Bildmodul `"id":""`, `wp-image-`, Bildgröße im Inhalt, Pods-Bildfeld, Website-Icon): `lit-media-delete` mit `confirm` aber ohne `force` → `lit_in_use` mit Verwendungsliste, nichts gelöscht → Tests in Task 1 (Finder) und Task 5. +2. **Import-Missbrauch:** URL auf `127.0.0.1`/interne Hosts, Datei über dem Limit, `.php` als `.png` getarnt, ungültiges Base64 → abgelehnt, temporäre Datei gelöscht, kein Anhang → Tests in Task 4. +3. **Unsinnige Tool-Einstellung:** `lit/media-preview` auf „indirekt“ (Bildergebnisse gehen nur direkt) oder `lit/guide` auf „deaktiviert“ → Policy fällt auf den Standard zurück; kaputte Optionswerte werden ignoriert → Test in Task 6. +4. **Benutzer ohne `lit_mcp_use` mit gültigem Token:** `tools/list` leer, Instructions ohne Website-Details; Listen fremder MCP-Server unverändert → Tests in Task 7. +5. **Löschen des Plugins:** eigene Optionen, Tabelle, Cron und Transient weg (auf Multisite je Website), mcp-oauth-Daten bleiben → Test in Task 12. + +--- + +## File Structure + +``` +wp-lit-mcp.php (geändert) Version 0.2.0 +readme.txt WordPress-Readme: Auswirkungen auf die Website, Deinstallation +uninstall.php entfernt eigene Daten +phpcs.xml.dist (geändert) uninstall.php prüfen +src/ + Plugin.php (geändert) Media, Policy, Sichtbarkeit, Admin + Lifecycle.php (geändert) Deaktivierung räumt Rewrite/Transient auf + Abilities/ + AbilityRegistrar.php (geändert) registriert nur nicht deaktivierte Abilities + ExposurePolicy.php direkt / indirekt / deaktiviert je Ability + ChangeLog/ChangeLog.php (geändert) query, abilities, clear + Integrations/ + IntegrationRegistry.php (geändert) directAbilityNames() entfällt + WordPress/Schema/ContentSchema.php (geändert) mediaFieldKeys() + Media/ + MediaIntegration.php liefert die Medien-Abilities + MediaGuard.php lädt Anhang + Rechteprüfung + MediaPresenter.php Ausgabeformat eines Mediums + MediaUsageFinder.php Verwendungssuche + MediaImporter.php Upload per URL/Base64 mit Whitelist und Limit + AdminIncludes.php lädt wp-admin/includes für Medienfunktionen + MediaSearchAbility.php lit/media-search + MediaGetAbility.php lit/media-get + MediaPreviewAbility.php lit/media-preview (ImageContent) + MediaDownloadAbility.php lit/media-download (eingebettete Ressource) + MediaUploadFromUrlAbility.php lit/media-upload-from-url + MediaUploadAbility.php lit/media-upload + MediaUpdateAbility.php lit/media-update (Stapel) + MediaDeleteAbility.php lit/media-delete + Server/ + ServerRegistrar.php (geändert) Tool-Liste aus ExposurePolicy + VisibilityFilter.php Tool-Liste/Instructions für Nicht-Freigegebene + Admin/ + AdminTab.php Interface eines Tabs + SettingsPage.php Menüpunkt, Tabs, Hinweise + ConnectionStatus.php Adapter/OAuth/HTTPS/Discovery/Endpunkt-Prüfung + ConnectionTab.php Tab „Verbindung“ + AdminNotices.php Admin-Hinweise (OAuth aus, Discovery kaputt, HTTPS) + SiteHealth.php Site-Health-Test „LIT MCP Endpunkt“ + ConnectionRepository.php OAuth-Verbindungen (Anwendungspasswörter von mcp-oauth) + AccessTab.php Tab „Zugriff“: Rollen, Verbindungen, Widerruf + ToolsTab.php Tab „Tools“: Exposition je Ability + LimitsTab.php Tab „Limits“ + LogTab.php Tab „Protokoll“: Filter, Tabelle, Leeren + LogListTable.php WP_List_Table des Protokolls +docs/guides/overview.md (geändert) Medien-Werkzeuge +docs/guides/media.md Anleitung Medien +tests/ + Stubs/WpApplicationPasswords.php Stub für Verbindungen + Unit/… Tests je Klasse + Unit/UninstallTest.php prüft uninstall.php + Integration/admin-render.php rendert alle Tabs serverseitig + Integration/media-admin-smoke.sh Ende-zu-Ende-Test Medien, Policy, Sichtbarkeit, Admin +``` + +--- + +### Task 1: Medien-Grundlagen – `MediaGuard`, `MediaPresenter`, `MediaUsageFinder`, `lit/media-get`, `MediaIntegration` + +Verwendungssuche (Spec §6.3): Anhang-ID als Beitragsbild, im Inhalt (`wp-image-`-Klasse, Divi-5-Attribut `"id":""`, Dateipfad inkl. Bildgrößen), in Datei-/Bildfeldern (Pods-`file`-Felder und Beziehungen auf Medien, Schlüssel aus `ContentSchema`) und als Website-Icon. + +**Files:** +- Create: `src/Integrations/Media/MediaGuard.php`, `src/Integrations/Media/MediaPresenter.php`, `src/Integrations/Media/MediaUsageFinder.php`, `src/Integrations/Media/MediaGetAbility.php`, `src/Integrations/Media/MediaIntegration.php` +- Modify: `src/Integrations/WordPress/Schema/ContentSchema.php` (Methode `mediaFieldKeys()`), `src/Plugin.php` (Integration registrieren) +- Test: `tests/Unit/Integrations/Media/MediaUsageFinderTest.php`, `tests/Unit/Integrations/Media/MediaPresenterTest.php`, `tests/Unit/Integrations/Media/MediaGetAbilityTest.php`, `tests/Unit/Integrations/Media/MediaIntegrationTest.php`, `tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php` (Test ergänzen) + +**Interfaces:** +- Consumes: `PostGuard::id()`, `ContentSchema::postTypeNames()/fields()`, `FieldDefinition` (1a), `Errors`, `JsonMap`, `ChangeLog`, `Limits` (1a). +- Produces: + - `ContentSchema::mediaFieldKeys(): list` – Schlüssel aller Felder mit `type === 'file'` oder `related.object_type === 'media'` über alle verwalteten Typen. + - `MediaGuard::attachment(mixed $id, string $capability = 'edit_post'): WP_Post|WP_Error`. + - `MediaPresenter` (nicht final): `summary(WP_Post $a): array{id, title, filename, mime_type, url, alt, width, height, filesize, date}`, `full(WP_Post $a, list $usages): array` (+ `caption`, `description`, `parent`, `sizes` (Map Name → `{url,width,height}`), `usages`, `edit_url`), `static summarySchema(): array`, `static fullSchema(): array`. + - `MediaUsageFinder` (nicht final): `__construct(Closure $mediaMetaKeys, ?\wpdb $db = null)`; `usages(int $attachmentId, int $max = 20): list`; `isUsed(int $attachmentId): bool`. + - `MediaGetAbility(MediaPresenter $presenter, MediaUsageFinder $finder)` – `lit/media-get`, Input `{id}`, Output `fullSchema()`; indirekt; readonly. + - `MediaIntegration`: `__construct(ContentSchema $schema, ChangeLog $log, Limits $limits)`, `id()` = `media`, immer verfügbar; `abilities()` wächst in Tasks 2–5. + +- [ ] **Step 1: Failing Tests** + +`tests/Unit/Integrations/Media/MediaUsageFinderTest.php`: +```php +justReturn('2026/09/foto.jpg'); + Functions\when('get_option')->justReturn('33'); + $db = Mockery::mock(\wpdb::class)->makePartial(); + $db->expects('get_results') + ->with(Mockery::on(static fn (string $sql): bool => str_contains($sql, "meta_key = '_thumbnail_id'") + && str_contains($sql, "meta_value = '33'"))) + ->andReturn([(object) ['ID' => 5, 'post_title' => 'Team', 'post_type' => 'page']]); + $db->expects('get_results') + ->with(Mockery::on(static fn (string $sql): bool => str_contains($sql, 'post_content LIKE') + && str_contains($sql, 'wp-image-33') + && str_contains($sql, '"id":"33"') + && str_contains($sql, '2026/09/foto.') + && str_contains($sql, '2026/09/foto-'))) + ->andReturn([ + (object) ['ID' => 5, 'post_title' => 'Team', 'post_type' => 'page'], + (object) ['ID' => 9, 'post_title' => 'Start', 'post_type' => 'page'], + ]); + $db->expects('get_results') + ->with(Mockery::on(static fn (string $sql): bool => str_contains($sql, "meta_key IN ('photo')"))) + ->andReturn([(object) ['ID' => 12, 'post_title' => 'Anna', 'post_type' => 'lit_person']]); + + $usages = (new MediaUsageFinder(static fn (): array => ['photo'], $db))->usages(33); + + self::assertSame( + [ + ['post_id' => 5, 'title' => 'Team', 'post_type' => 'page', 'where' => 'featured'], + ['post_id' => 9, 'title' => 'Start', 'post_type' => 'page', 'where' => 'content'], + ['post_id' => 12, 'title' => 'Anna', 'post_type' => 'lit_person', 'where' => 'field'], + ['post_id' => 0, 'title' => 'Website-Icon', 'post_type' => 'option', 'where' => 'site_icon'], + ], + $usages + ); + } + + public function testNoFieldQueryWithoutMediaFieldsAndUnusedMedia(): void + { + Functions\when('get_post_meta')->justReturn(''); + Functions\when('get_option')->justReturn('0'); + $db = Mockery::mock(\wpdb::class)->makePartial(); + $db->expects('get_results')->twice()->andReturn([]); + + self::assertFalse((new MediaUsageFinder(static fn (): array => [], $db))->isUsed(33)); + } +} +``` + +`tests/Unit/Integrations/Media/MediaPresenterTest.php`: +```php + 33, + 'post_type' => 'attachment', + 'post_title' => 'Foto', + 'post_mime_type' => 'image/jpeg', + 'post_excerpt' => 'Bildunterschrift', + 'post_content' => 'Beschreibung', + 'post_parent' => 5, + ]); + } + + private function stubCommon(array $meta): void + { + Functions\when('wp_get_attachment_metadata')->justReturn($meta); + Functions\when('get_attached_file')->justReturn('/var/www/html/wp-content/uploads/2026/09/foto.jpg'); + Functions\when('wp_basename')->alias('basename'); + Functions\when('wp_get_attachment_url')->justReturn('https://kunde.de/wp-content/uploads/2026/09/foto.jpg'); + Functions\when('get_post_meta')->justReturn('Porträt'); + Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=33&action=edit'); + } + + public function testSummaryHasFileDataAndAlt(): void + { + $this->stubCommon(['width' => 1600, 'height' => 900, 'filesize' => 123456]); + + $summary = (new MediaPresenter())->summary($this->attachment()); + + self::assertSame('foto.jpg', $summary['filename']); + self::assertSame('Porträt', $summary['alt']); + self::assertSame(1600, $summary['width']); + self::assertSame(123456, $summary['filesize']); + } + + public function testFullListsSizesAndUsagesAndCopesWithMissingMetadata(): void + { + $this->stubCommon(['sizes' => ['medium' => ['width' => 300, 'height' => 169]]]); + Functions\when('wp_get_attachment_image_src')->justReturn(['https://kunde.de/foto-300x169.jpg', 300, 169, true]); + $usages = [['post_id' => 5, 'title' => 'Team', 'post_type' => 'page', 'where' => 'featured']]; + + $full = (new MediaPresenter())->full($this->attachment(), $usages); + + self::assertSame(['medium' => ['url' => 'https://kunde.de/foto-300x169.jpg', 'width' => 300, 'height' => 169]], $full['sizes']); + self::assertSame($usages, $full['usages']); + self::assertSame('Bildunterschrift', $full['caption']); + self::assertNull($full['width']); + + Functions\when('wp_get_attachment_metadata')->justReturn(false); + self::assertInstanceOf(stdClass::class, (new MediaPresenter())->full($this->attachment(), [])['sizes']); + } +} +``` + +`tests/Unit/Integrations/Media/MediaGetAbilityTest.php`: +```php + 33, 'post_type' => 'attachment']); + Functions\when('get_post')->justReturn($attachment); + Functions\when('current_user_can')->justReturn(true); + $finder = Mockery::mock(MediaUsageFinder::class); + $finder->expects('usages')->with(33)->andReturn([]); + $presenter = Mockery::mock(MediaPresenter::class); + $presenter->expects('full')->with($attachment, [])->andReturn(['id' => 33]); + + self::assertSame(['id' => 33], (new MediaGetAbility($presenter, $finder))->execute(['id' => 33])); + } + + public function testNonAttachmentIsNotFound(): void + { + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'page'])); + $ability = new MediaGetAbility(Mockery::mock(MediaPresenter::class), Mockery::mock(MediaUsageFinder::class)); + + self::assertSame('lit_not_found', $ability->permission(['id' => 5])->get_error_code()); + } +} +``` + +`tests/Unit/Integrations/Media/MediaIntegrationTest.php`: +```php +id()); + self::assertTrue($integration->isAvailable()); + self::assertSame(self::EXPECTED, array_map(static fn (Ability $a): string => $a->name(), $integration->abilities())); + + foreach ($integration->abilities() as $ability) { + self::assertFalse($ability->inputSchema()['additionalProperties'], $ability->name()); + } + } +} +``` + +In `tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php` ergänzen: +```php + public function testMediaFieldKeysCoverFileFieldsAndMediaRelations(): void + { + Functions\when('get_post_types')->justReturn(['page' => 'page', 'lit_person' => 'lit_person']); + $source = Mockery::mock(FieldSource::class); + $source->allows('id')->andReturn('pods'); + $source->allows('fieldsFor')->andReturnUsing(static fn (string $type): array => $type === 'lit_person' + ? [ + new FieldDefinition('photo', 'Foto', 'file', 'pods', 'writable'), + new FieldDefinition('logo', 'Logo', 'relation', 'pods', 'writable', false, false, null, [ + 'object_type' => 'media', + 'name' => null, + ]), + new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable'), + ] + : [new FieldDefinition('photo', 'Foto', 'file', 'pods', 'writable')]); + + self::assertSame(['photo', 'logo'], (new ContentSchema([$source]))->mediaFieldKeys()); + } +``` + +- [ ] **Step 2: Run – muss fehlschlagen** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test -- --filter 'Media|ContentSchemaTest'` +Expected: FAIL (Klassen fehlen, `mediaFieldKeys()` fehlt). + +- [ ] **Step 3: Implementierung** + +In `src/Integrations/WordPress/Schema/ContentSchema.php` nach `sourceOf()` ergänzen: +```php + /** + * Keys of fields that store attachment IDs (file fields, relations to media) – + * used by the media usage search. + * + * @return list + */ + public function mediaFieldKeys(): array + { + $keys = []; + foreach ($this->postTypeNames() as $postType) { + foreach ($this->fields($postType) as $field) { + if ($field->type === 'file' || ($field->related['object_type'] ?? null) === 'media') { + $keys[] = $field->key; + } + } + } + + return array_values(array_unique($keys)); + } +``` + +`src/Integrations/Media/MediaGuard.php`: +```php + 'positive Ganzzahl erwartet']); + } + + $attachment = get_post($attachmentId); + if (! $attachment instanceof WP_Post || $attachment->post_type !== 'attachment') { + return Errors::notFound( + sprintf('Medium %d nicht gefunden.', $attachmentId), + 'Medien über lit-media-search suchen.' + ); + } + + if (! current_user_can($capability, $attachmentId)) { + return Errors::forbidden(sprintf('Keine Berechtigung für Medium %d.', $attachmentId)); + } + + return $attachment; + } +} +``` + +`src/Integrations/Media/MediaPresenter.php`: +```php + */ + public function summary(WP_Post $attachment): array + { + $meta = $this->metadata($attachment); + + return [ + 'id' => (int) $attachment->ID, + 'title' => (string) $attachment->post_title, + 'filename' => wp_basename((string) get_attached_file((int) $attachment->ID)), + 'mime_type' => (string) $attachment->post_mime_type, + 'url' => (string) wp_get_attachment_url((int) $attachment->ID), + 'alt' => (string) get_post_meta((int) $attachment->ID, '_wp_attachment_image_alt', true), + 'width' => isset($meta['width']) ? (int) $meta['width'] : null, + 'height' => isset($meta['height']) ? (int) $meta['height'] : null, + 'filesize' => isset($meta['filesize']) ? (int) $meta['filesize'] : null, + 'date' => (string) $attachment->post_date, + ]; + } + + /** + * @param list> $usages + * @return array + */ + public function full(WP_Post $attachment, array $usages): array + { + $sizes = []; + foreach ((array) ($this->metadata($attachment)['sizes'] ?? []) as $name => $size) { + $source = wp_get_attachment_image_src((int) $attachment->ID, (string) $name); + if (is_array($source)) { + $sizes[(string) $name] = [ + 'url' => (string) $source[0], + 'width' => (int) $source[1], + 'height' => (int) $source[2], + ]; + } + } + + return array_merge($this->summary($attachment), [ + 'caption' => (string) $attachment->post_excerpt, + 'description' => (string) $attachment->post_content, + 'parent' => (int) $attachment->post_parent, + 'sizes' => JsonMap::of($sizes), + 'usages' => $usages, + 'edit_url' => (string) get_edit_post_link((int) $attachment->ID, 'raw'), + ]); + } + + /** @return array */ + public static function summarySchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'title' => ['type' => 'string'], + 'filename' => ['type' => 'string'], + 'mime_type' => ['type' => 'string'], + 'url' => ['type' => 'string'], + 'alt' => ['type' => 'string'], + 'width' => ['type' => ['integer', 'null']], + 'height' => ['type' => ['integer', 'null']], + 'filesize' => ['type' => ['integer', 'null']], + 'date' => ['type' => 'string'], + ], + 'required' => ['id', 'title', 'mime_type', 'url', 'alt'], + ]; + } + + /** @return array */ + public static function fullSchema(): array + { + $schema = self::summarySchema(); + $schema['properties'] = array_merge($schema['properties'], [ + 'caption' => ['type' => 'string'], + 'description' => ['type' => 'string'], + 'parent' => ['type' => 'integer'], + 'sizes' => ['type' => 'object'], + 'usages' => ['type' => 'array', 'items' => ['type' => 'object']], + 'edit_url' => ['type' => 'string'], + ]); + $schema['required'] = array_merge($schema['required'], ['sizes', 'usages']); + + return $schema; + } + + /** @return array */ + private function metadata(WP_Post $attachment): array + { + $meta = wp_get_attachment_metadata((int) $attachment->ID); + + return is_array($meta) ? $meta : []; + } +} +``` + +`src/Integrations/Media/MediaUsageFinder.php`: +```php + $mediaMetaKeys Keys of file/image fields (ContentSchema::mediaFieldKeys()). + */ + public function __construct(private Closure $mediaMetaKeys, private ?\wpdb $db = null) + { + } + + /** @return list */ + public function usages(int $attachmentId, int $max = 20): array + { + $wpdb = $this->db ?? $GLOBALS['wpdb']; + $found = []; + $this->collect($found, $this->featured($wpdb, $attachmentId, $max), 'featured'); + $this->collect($found, $this->content($wpdb, $attachmentId, $max), 'content'); + $this->collect($found, $this->fields($wpdb, $attachmentId, $max), 'field'); + + if ((int) get_option('site_icon') === $attachmentId) { + $found['site_icon'] = [ + 'post_id' => 0, + 'title' => 'Website-Icon', + 'post_type' => 'option', + 'where' => 'site_icon', + ]; + } + + return array_slice(array_values($found), 0, $max); + } + + public function isUsed(int $attachmentId): bool + { + return $this->usages($attachmentId, 1) !== []; + } + + /** @return list */ + private function featured(\wpdb $wpdb, int $attachmentId, int $max): array + { + return (array) $wpdb->get_results($wpdb->prepare( + "SELECT p.ID, p.post_title, p.post_type FROM {$wpdb->postmeta} pm + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id + WHERE pm.meta_key = '_thumbnail_id' AND pm.meta_value = %s + AND p.post_status NOT IN ('auto-draft', 'trash', 'inherit') LIMIT %d", + (string) $attachmentId, + $max + )); + } + + /** @return list */ + private function content(\wpdb $wpdb, int $attachmentId, int $max): array + { + $patterns = ['wp-image-' . $attachmentId, '"id":"' . $attachmentId . '"']; + $file = (string) get_post_meta($attachmentId, '_wp_attached_file', true); + $base = (string) preg_replace('/\.[^.\/]+$/', '', $file); + if ($base !== '') { + // Original file and generated sizes (foto.jpg, foto-300x200.jpg). + $patterns[] = $base . '.'; + $patterns[] = $base . '-'; + } + + $likes = array_map(static fn (string $pattern): string => '%' . $wpdb->esc_like($pattern) . '%', $patterns); + $conditions = implode(' OR ', array_fill(0, count($likes), 'p.post_content LIKE %s')); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $conditions only holds placeholders. + return (array) $wpdb->get_results($wpdb->prepare( + "SELECT p.ID, p.post_title, p.post_type FROM {$wpdb->posts} p + WHERE p.post_type NOT IN ('revision', 'attachment', 'nav_menu_item') + AND p.post_status NOT IN ('auto-draft', 'trash', 'inherit') + AND ({$conditions}) LIMIT %d", + ...array_merge($likes, [$max]) + )); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + + /** @return list */ + private function fields(\wpdb $wpdb, int $attachmentId, int $max): array + { + $keys = ($this->mediaMetaKeys)(); + if ($keys === []) { + return []; + } + + $placeholders = implode(', ', array_fill(0, count($keys), '%s')); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $placeholders only holds placeholders. + return (array) $wpdb->get_results($wpdb->prepare( + "SELECT DISTINCT p.ID, p.post_title, p.post_type FROM {$wpdb->postmeta} pm + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id + WHERE pm.meta_key IN ({$placeholders}) AND pm.meta_value = %s + AND p.post_status NOT IN ('auto-draft', 'trash', 'inherit') LIMIT %d", + ...array_merge($keys, [(string) $attachmentId, $max]) + )); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + + /** + * @param array $found + * @param list $rows + */ + private function collect(array &$found, array $rows, string $where): void + { + foreach ($rows as $row) { + $found['post:' . (int) $row->ID] ??= [ + 'post_id' => (int) $row->ID, + 'title' => (string) $row->post_title, + 'post_type' => (string) $row->post_type, + 'where' => $where, + ]; + } + } +} +``` + +`src/Integrations/Media/MediaGetAbility.php`: +```php + 'object', + 'properties' => ['id' => ['type' => 'integer', 'minimum' => 1]], + 'required' => ['id'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return MediaPresenter::fullSchema(); + } + + 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 + { + $attachment = MediaGuard::attachment($input['id'] ?? null); + + return $attachment instanceof WP_Error ? $attachment : true; + } + + public function execute(array $input): mixed + { + $attachment = MediaGuard::attachment($input['id'] ?? null); + if ($attachment instanceof WP_Error) { + return $attachment; + } + + return $this->presenter->full($attachment, $this->finder->usages((int) $attachment->ID)); + } +} +``` + +`src/Integrations/Media/MediaIntegration.php`: +```php + $this->schema->mediaFieldKeys()); + + return [ + new MediaGetAbility($presenter, $finder), + ]; + } +} +``` + +`src/Plugin.php`: Import `use LIT\MCP\Integrations\Media\MediaIntegration;` ergänzen und `boot()` vollständig ersetzen durch: +```php + 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(); + + $limits = new Limits(); + $changeLog = new ChangeLog(); + (new ChangeLogInstaller($changeLog))->maybeInstall(); + (new ChangeLogCleanup($changeLog, $limits))->register(); + + $schema = self::contentSchema(); + $registry = new IntegrationRegistry( + new CoreIntegration(self::pluginDir() . '/docs/guides'), + new WordPressIntegration($schema, $changeLog), + new MediaIntegration($schema, $changeLog, $limits) + ); + + (new AbilityRegistrar($registry))->register(); + (new ServerRegistrar( + $registry, + new InstructionsBuilder($registry, static fn (): array => $schema->postTypeNames()) + ))->register(); + + \WPMedia\MCP\OAuth\Bootstrap::instance(); + } +``` + +- [ ] **Step 4: Run – muss bestehen** + +Run: `composer test && composer lint` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Media src/Integrations/WordPress/Schema/ContentSchema.php src/Plugin.php tests/Unit/Integrations/Media tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: media usage finder and media-get tool + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- +### Task 2: `lit/media-search` inkl. `missing_alt` und `unused` + +**Files:** +- Create: `src/Integrations/Media/MediaSearchAbility.php` +- Modify: `src/Integrations/Media/MediaIntegration.php`, `tests/Unit/Integrations/Media/MediaIntegrationTest.php` +- Test: `tests/Unit/Integrations/Media/MediaSearchAbilityTest.php` + +**Interfaces:** +- Consumes: `MediaPresenter::summary()/summarySchema()`, `MediaUsageFinder::isUsed()` (Task 1), `Cursor` (1a). +- Produces: `MediaSearchAbility(MediaPresenter $presenter, MediaUsageFinder $finder)` – `lit/media-search`, Input `{search?, mime?, date_after?, date_before?, missing_alt?, unused?, limit?, cursor?}`, Output `{items: list, next_cursor: ?string, warnings: list}`; direkt; readonly, idempotent. `unused` prüft höchstens `MAX_SCAN = 500` Medien je Aufruf (in Blöcken zu 50) und meldet eine Unterbrechung als Warnung mit `next_cursor`. + +- [ ] **Step 1: Failing Test** + +`tests/Unit/Integrations/Media/MediaSearchAbilityTest.php`: +```php +allows('summary')->andReturnUsing(static fn (WP_Post $a): array => ['id' => $a->ID]); + + return $presenter; + } + + /** @return list */ + private function attachments(int ...$ids): array + { + return array_map(static fn (int $id): WP_Post => new WP_Post(['ID' => $id, 'post_type' => 'attachment']), $ids); + } + + public function testMissingAltLimitsToImagesWithoutAltText(): void + { + Functions\expect('get_posts')->once()->with(Mockery::on(static fn (array $args): bool => + $args['post_type'] === 'attachment' + && $args['post_mime_type'] === 'image' + && $args['s'] === 'team' + && $args['meta_query'] === [ + 'relation' => 'OR', + ['key' => '_wp_attachment_image_alt', 'compare' => 'NOT EXISTS'], + ['key' => '_wp_attachment_image_alt', 'value' => '', 'compare' => '='], + ] + && $args['posts_per_page'] === 21 + && $args['offset'] === 0))->andReturn($this->attachments(1, 2)); + + $result = (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class))) + ->execute(['search' => 'team', 'missing_alt' => true]); + + self::assertSame([['id' => 1], ['id' => 2]], $result['items']); + self::assertNull($result['next_cursor']); + self::assertSame([], $result['warnings']); + } + + public function testUnusedFiltersThroughTheUsageFinder(): void + { + Functions\when('get_posts')->justReturn($this->attachments(1, 2, 3)); + $finder = Mockery::mock(MediaUsageFinder::class); + $finder->allows('isUsed')->andReturnUsing(static fn (int $id): bool => $id === 2); + + $result = (new MediaSearchAbility($this->presenter(), $finder))->execute(['unused' => true]); + + self::assertSame([['id' => 1], ['id' => 3]], $result['items']); + self::assertNull($result['next_cursor']); + } + + public function testUnusedStopsAtTheLimitWithACursor(): void + { + Functions\when('get_posts')->justReturn($this->attachments(1, 2, 3)); + $finder = Mockery::mock(MediaUsageFinder::class); + $finder->allows('isUsed')->andReturn(false); + + $result = (new MediaSearchAbility($this->presenter(), $finder))->execute(['unused' => true, 'limit' => 1]); + + self::assertSame([['id' => 1]], $result['items']); + self::assertSame(1, Cursor::decode($result['next_cursor'])); + } + + public function testRequiresUploadRights(): void + { + Functions\when('current_user_can')->justReturn(false); + $ability = new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class)); + + self::assertSame('lit_forbidden', $ability->permission([])->get_error_code()); + self::assertTrue($ability->isDirect()); + } +} +``` + +In `MediaIntegrationTest` die Konstante ersetzen: +```php + private const EXPECTED = [ + 'lit/media-search', + 'lit/media-get', + ]; +``` + +- [ ] **Step 2: Run – muss fehlschlagen** + +Run: `composer test -- --filter 'MediaSearchAbilityTest|MediaIntegrationTest'` +Expected: FAIL. + +- [ ] **Step 3: Implementierung** + +`src/Integrations/Media/MediaSearchAbility.php`: +```php + 'object', + 'properties' => array_merge([ + 'search' => ['type' => 'string'], + 'mime' => ['type' => 'string', 'pattern' => '^[a-z]+(/[a-z0-9.+-]+)?$'], + 'date_after' => ['type' => 'string', 'pattern' => self::DATE_PATTERN], + 'date_before' => ['type' => 'string', 'pattern' => self::DATE_PATTERN], + 'missing_alt' => ['type' => 'boolean'], + 'unused' => ['type' => 'boolean'], + ], Cursor::inputProperties()), + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'items' => ['type' => 'array', 'items' => MediaPresenter::summarySchema()], + 'next_cursor' => ['type' => ['string', 'null']], + 'warnings' => ['type' => 'array', 'items' => ['type' => 'string']], + ], + 'required' => ['items', 'next_cursor', 'warnings'], + ]; + } + + 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('upload_files') + ? true + : Errors::forbidden('Keine Berechtigung für die Mediathek (upload_files).'); + } + + 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); + $args = $this->queryArgs($input); + + if (($input['unused'] ?? false) === true) { + $page = $this->unusedPage($args, $limit, $offset); + } else { + $posts = get_posts(array_merge($args, ['posts_per_page' => $limit + 1, 'offset' => $offset])); + $page = Cursor::page(array_values($posts), $limit, $offset) + ['warnings' => []]; + } + + return [ + 'items' => array_map( + fn (WP_Post $attachment): array => $this->presenter->summary($attachment), + $page['items'] + ), + 'next_cursor' => $page['next_cursor'], + 'warnings' => $page['warnings'], + ]; + } + + /** + * @param array $input + * @return array + */ + private function queryArgs(array $input): array + { + $args = [ + 'post_type' => 'attachment', + 'post_status' => 'inherit', + 'orderby' => 'date', + 'order' => 'DESC', + 'no_found_rows' => true, + 'suppress_filters' => false, + ]; + + if (isset($input['search']) && $input['search'] !== '') { + $args['s'] = (string) $input['search']; + } + + if (isset($input['mime']) && $input['mime'] !== '') { + $args['post_mime_type'] = (string) $input['mime']; + } + + if (($input['missing_alt'] ?? false) === true) { + $args['post_mime_type'] ??= 'image'; + $args['meta_query'] = [ + 'relation' => 'OR', + ['key' => '_wp_attachment_image_alt', 'compare' => 'NOT EXISTS'], + ['key' => '_wp_attachment_image_alt', 'value' => '', 'compare' => '='], + ]; + } + + $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 $args + * @return array{items: list, next_cursor: ?string, warnings: list} + */ + private function unusedPage(array $args, int $limit, int $offset): array + { + $items = []; + $position = $offset; + $scanned = 0; + + while ($scanned < self::MAX_SCAN) { + $batch = array_values(get_posts(array_merge($args, [ + 'posts_per_page' => self::BATCH, + 'offset' => $position, + ]))); + + foreach ($batch as $attachment) { + $position++; + $scanned++; + if (! $this->finder->isUsed((int) $attachment->ID)) { + $items[] = $attachment; + if (count($items) === $limit) { + return ['items' => $items, 'next_cursor' => Cursor::encode($position), 'warnings' => []]; + } + } + } + + if (count($batch) < self::BATCH) { + return ['items' => $items, 'next_cursor' => null, 'warnings' => []]; + } + } + + return [ + 'items' => $items, + 'next_cursor' => Cursor::encode($position), + 'warnings' => [sprintf( + 'Suche nach unbenutzten Medien nach %d geprüften Medien unterbrochen; mit next_cursor fortsetzen.', + self::MAX_SCAN + )], + ]; + } +} +``` + +`src/Integrations/Media/MediaIntegration.php`: `abilities()`-Rückgabe ersetzen durch +```php + return [ + new MediaSearchAbility($presenter, $finder), + new MediaGetAbility($presenter, $finder), + ]; +``` + +- [ ] **Step 4: Run – muss bestehen** + +Run: `composer test && composer lint` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Media tests/Unit/Integrations/Media +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: media-search with missing_alt and unused filters + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- + +### Task 3: `lit/media-preview` (ImageContent) und `lit/media-download` (eingebettete Ressource) + +Der Adapter wandelt ein Ergebnis `['type' => 'image', 'results' => , 'mimeType' => …]` in `ImageContent` und `['type' => 'resource', 'resource' => ['uri', 'mimeType', 'blob']]` in eine eingebettete Ressource um (`ToolsHandler::call_tool`). Solche Tools haben kein `outputSchema`; Bilder funktionieren nur als direktes Tool (Spec §4.3) – `ExposurePolicy` erzwingt das in Task 6. + +**Files:** +- Create: `src/Integrations/Media/AdminIncludes.php`, `src/Integrations/Media/MediaPreviewAbility.php`, `src/Integrations/Media/MediaDownloadAbility.php` +- Modify: `src/Integrations/Media/MediaIntegration.php`, `tests/Unit/Integrations/Media/MediaIntegrationTest.php` +- Test: `tests/Unit/Integrations/Media/MediaPreviewAbilityTest.php`, `tests/Unit/Integrations/Media/MediaDownloadAbilityTest.php` + +**Interfaces:** +- Consumes: `MediaGuard` (Task 1), `Limits::uploadBytes()/uploadMegabytes()` (1a), `Errors`. +- Produces: + - `AdminIncludes::load(): void` – lädt `wp-admin/includes/file.php`, `media.php`, `image.php`, sofern deren Funktionen fehlen und die Datei existiert. + - `MediaPreviewAbility()` – `lit/media-preview`, Input `{id, max_size? (64–1024, Standard 512)}`, Ergebnis `{type: 'image', results: , mimeType}`; direkt; readonly, idempotent; `outputSchema()` = `null`. + - `MediaDownloadAbility(Limits $limits)` – `lit/media-download`, Input `{id, size? (Standard full)}`, Ergebnis `{type: 'resource', resource: {uri, mimeType, blob}}`; indirekt; readonly, idempotent; `outputSchema()` = `null`; Dateien über dem Upload-Limit werden abgelehnt. + +- [ ] **Step 1: Failing Tests** + +`tests/Unit/Integrations/Media/MediaPreviewAbilityTest.php`: +```php +source = (string) tempnam(sys_get_temp_dir(), 'lit-src'); + file_put_contents($this->source, 'original'); + Functions\when('get_post')->justReturn(new WP_Post([ + 'ID' => 33, + 'post_type' => 'attachment', + 'post_mime_type' => 'image/jpeg', + ])); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_attached_file')->justReturn($this->source); + Functions\when('wp_tempnam')->alias(static fn (): string => (string) tempnam(sys_get_temp_dir(), 'lit-prev')); + Functions\when('wp_delete_file')->alias(static function (string $file): void { + if (is_file($file)) { + unlink($file); + } + }); + } + + protected function tearDown(): void + { + if (is_file($this->source)) { + unlink($this->source); + } + parent::tearDown(); + } + + public function testReturnsADownscaledImageResult(): void + { + Functions\when('wp_attachment_is_image')->justReturn(true); + $editor = Mockery::mock(); + $editor->allows('get_size')->andReturn(['width' => 2000, 'height' => 1000]); + $editor->expects('resize')->with(256, 256, false)->andReturn(true); + $editor->expects('save')->andReturnUsing(static function (string $path, string $mime): array { + file_put_contents($path, 'JPEGDATA'); + return ['path' => $path, 'mime-type' => $mime]; + }); + Functions\when('wp_get_image_editor')->justReturn($editor); + + $result = (new MediaPreviewAbility())->execute(['id' => 33, 'max_size' => 256]); + + self::assertSame('image', $result['type']); + self::assertSame('JPEGDATA', $result['results']); + self::assertSame('image/jpeg', $result['mimeType']); + } + + public function testNonImagesAreUnsupported(): void + { + Functions\when('wp_attachment_is_image')->justReturn(false); + + $result = (new MediaPreviewAbility())->execute(['id' => 33]); + + self::assertSame('lit_unsupported', $result->get_error_code()); + } + + public function testIsADirectToolWithoutOutputSchema(): void + { + $ability = new MediaPreviewAbility(); + + self::assertNull($ability->outputSchema()); + self::assertTrue($ability->isDirect()); + self::assertTrue($ability->annotations()['readonly']); + } +} +``` + +`tests/Unit/Integrations/Media/MediaDownloadAbilityTest.php`: +```php +file = sys_get_temp_dir() . '/lit-download-test.pdf'; + file_put_contents($this->file, 'PDFDATA'); + Functions\when('get_post')->justReturn(new WP_Post([ + 'ID' => 40, + 'post_type' => 'attachment', + 'post_mime_type' => 'application/pdf', + ])); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_attached_file')->justReturn($this->file); + Functions\when('wp_get_attachment_url')->justReturn('https://kunde.de/wp-content/uploads/preise.pdf'); + Functions\when('wp_basename')->alias('basename'); + Functions\when('wp_check_filetype')->justReturn(['ext' => 'pdf', 'type' => 'application/pdf']); + } + + protected function tearDown(): void + { + if (is_file($this->file)) { + unlink($this->file); + } + parent::tearDown(); + } + + public function testReturnsTheFileAsEmbeddedBlobResource(): void + { + Functions\when('get_option')->justReturn([]); + + $result = (new MediaDownloadAbility(new Limits()))->execute(['id' => 40]); + + self::assertSame('resource', $result['type']); + self::assertSame('https://kunde.de/wp-content/uploads/preise.pdf', $result['resource']['uri']); + self::assertSame('application/pdf', $result['resource']['mimeType']); + self::assertSame(base64_encode('PDFDATA'), $result['resource']['blob']); + } + + public function testFilesAboveTheLimitAreRefused(): void + { + Functions\when('get_option')->justReturn(['upload_mb' => 1]); + file_put_contents($this->file, str_repeat('x', 1024 * 1024 + 1)); + + $result = (new MediaDownloadAbility(new Limits()))->execute(['id' => 40]); + + self::assertSame('lit_unsupported', $result->get_error_code()); + } + + public function testUnknownSizeIsNotFound(): void + { + Functions\when('get_option')->justReturn([]); + Functions\when('image_get_intermediate_size')->justReturn(false); + + $result = (new MediaDownloadAbility(new Limits()))->execute(['id' => 40, 'size' => 'huge']); + + self::assertSame('lit_not_found', $result->get_error_code()); + self::assertNull((new MediaDownloadAbility(new Limits()))->outputSchema()); + } +} +``` + +In `MediaIntegrationTest` die Konstante ersetzen: +```php + private const EXPECTED = [ + 'lit/media-search', + 'lit/media-get', + 'lit/media-preview', + 'lit/media-download', + ]; +``` + +- [ ] **Step 2: Run – muss fehlschlagen** + +Run: `composer test -- --filter 'MediaPreviewAbilityTest|MediaDownloadAbilityTest|MediaIntegrationTest'` +Expected: FAIL. + +- [ ] **Step 3: Implementierung** + +`src/Integrations/Media/AdminIncludes.php`: +```php + 'wp-admin/includes/file.php', + 'media_handle_sideload' => 'wp-admin/includes/media.php', + 'wp_generate_attachment_metadata' => 'wp-admin/includes/image.php', + ]; + + public static function load(): void + { + foreach (self::FILES as $function => $file) { + if (! function_exists($function) && is_readable(ABSPATH . $file)) { + require_once ABSPATH . $file; + } + } + } +} +``` + +`src/Integrations/Media/MediaPreviewAbility.php`: +```php + 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', + 'image/gif' => 'gif', + ]; + + public function name(): string + { + return 'lit/media-preview'; + } + + public function label(): string + { + return 'Bildvorschau'; + } + + public function description(): string + { + return 'Zeigt ein Bild der Mediathek als verkleinerte Vorschau (längste Kante max_size, Standard 512 px), ' + . 'damit der Inhalt beurteilt werden kann, z. B. für Alt-Texte.'; + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer', 'minimum' => 1], + 'max_size' => ['type' => 'integer', 'minimum' => 64, 'maximum' => 1024], + ], + 'required' => ['id'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return null; + } + + 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 + { + $attachment = MediaGuard::attachment($input['id'] ?? null); + + return $attachment instanceof WP_Error ? $attachment : true; + } + + public function execute(array $input): mixed + { + $attachment = MediaGuard::attachment($input['id'] ?? null); + if ($attachment instanceof WP_Error) { + return $attachment; + } + + $id = (int) $attachment->ID; + if (! wp_attachment_is_image($id)) { + return Errors::unsupported( + 'Das Medium ist kein Bild.', + 'Metadaten über lit-media-get abrufen; Dateien über lit-media-download.' + ); + } + + $file = get_attached_file($id); + if (! is_string($file) || ! is_readable($file)) { + return Errors::notFound('Die Bilddatei fehlt auf dem Server.', 'Medium in der Mediathek prüfen.'); + } + + AdminIncludes::load(); + $editor = wp_get_image_editor($file); + if ($editor instanceof WP_Error) { + return Errors::unsupported( + 'Das Bild kann nicht verarbeitet werden: ' . $editor->get_error_message(), + 'Metadaten über lit-media-get abrufen.' + ); + } + + $max = max(64, min(1024, (int) ($input['max_size'] ?? self::DEFAULT_SIZE))); + $size = (array) $editor->get_size(); + if (max((int) ($size['width'] ?? 0), (int) ($size['height'] ?? 0)) > $max) { + $resized = $editor->resize($max, $max, false); + if ($resized instanceof WP_Error) { + return Errors::unsupported( + 'Vorschau konnte nicht verkleinert werden.', + 'Metadaten über lit-media-get abrufen.' + ); + } + } + + $mime = isset(self::EXTENSIONS[$attachment->post_mime_type]) + ? (string) $attachment->post_mime_type + : 'image/jpeg'; + $target = wp_tempnam('lit-mcp-preview'); + $saved = $editor->save($target . '.' . self::EXTENSIONS[$mime], $mime); + wp_delete_file($target); + if (! is_array($saved) || ! isset($saved['path'])) { + return Errors::unsupported( + 'Vorschau konnte nicht erzeugt werden.', + 'Metadaten über lit-media-get abrufen.' + ); + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local temp file. + $bytes = file_get_contents((string) $saved['path']); + wp_delete_file((string) $saved['path']); + if ($bytes === false) { + return Errors::internal(); + } + + return ['type' => 'image', 'results' => $bytes, 'mimeType' => (string) ($saved['mime-type'] ?? $mime)]; + } +} +``` + +`src/Integrations/Media/MediaDownloadAbility.php`: +```php + 'object', + 'properties' => [ + 'id' => ['type' => 'integer', 'minimum' => 1], + 'size' => ['type' => 'string', 'description' => 'full (Standard) oder Name einer Bildgröße.'], + ], + 'required' => ['id'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return null; + } + + 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 + { + $attachment = MediaGuard::attachment($input['id'] ?? null); + + return $attachment instanceof WP_Error ? $attachment : true; + } + + public function execute(array $input): mixed + { + $attachment = MediaGuard::attachment($input['id'] ?? null); + if ($attachment instanceof WP_Error) { + return $attachment; + } + + $id = (int) $attachment->ID; + $size = (string) ($input['size'] ?? 'full'); + if ($size === 'full') { + $path = get_attached_file($id); + $url = (string) wp_get_attachment_url($id); + } else { + $data = image_get_intermediate_size($id, $size); + if (! is_array($data) || ! isset($data['path'])) { + return Errors::notFound( + sprintf('Die Größe "%s" gibt es für dieses Medium nicht.', $size), + 'Verfügbare Größen stehen unter sizes in lit-media-get.' + ); + } + $path = wp_get_upload_dir()['basedir'] . '/' . $data['path']; + $url = (string) ($data['url'] ?? ''); + } + + if (! is_string($path) || ! is_readable($path)) { + return Errors::notFound('Die Datei fehlt auf dem Server.', 'Medium in der Mediathek prüfen.'); + } + + $bytesOnDisk = (int) filesize($path); + if ($bytesOnDisk > $this->limits->uploadBytes()) { + return Errors::unsupported( + sprintf( + 'Die Datei ist zu groß für die Übertragung (%.1f MB, Limit %d MB).', + $bytesOnDisk / 1048576, + $this->limits->uploadMegabytes() + ), + 'Kleinere Bildgröße (size) wählen oder die URL aus lit-media-get verwenden.' + ); + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local upload file. + $bytes = file_get_contents($path); + if ($bytes === false) { + return Errors::internal(); + } + + $type = wp_check_filetype(wp_basename($path)); + $mime = is_string($type['type'] ?? null) && $type['type'] !== '' + ? $type['type'] + : (string) $attachment->post_mime_type; + + return [ + 'type' => 'resource', + 'resource' => ['uri' => $url, 'mimeType' => $mime, 'blob' => base64_encode($bytes)], + ]; + } +} +``` + +`src/Integrations/Media/MediaIntegration.php`: `abilities()`-Rückgabe ersetzen durch +```php + return [ + new MediaSearchAbility($presenter, $finder), + new MediaGetAbility($presenter, $finder), + new MediaPreviewAbility(), + new MediaDownloadAbility($this->limits), + ]; +``` + +- [ ] **Step 4: Run – muss bestehen** + +Run: `composer test && composer lint` +Expected: PASS (lange Zeilen umbrechen). + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Media tests/Unit/Integrations/Media +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: media preview as image content and download as embedded resource + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- +### Task 4: Upload – `MediaImporter`, `lit/media-upload-from-url`, `lit/media-upload` + +Spec §6.3/§10: Import per URL über `wp_safe_remote_get` (hier `download_url()`, das intern `wp_safe_remote_get` nutzt; vorher `wp_http_validate_url()`, damit interne Adressen gar nicht erst angefragt werden), MIME-Whitelist, Größenlimit; Base64-Upload mit einstellbarem Limit (Standard 5 MB). `wp_check_filetype_and_ext()` prüft zusätzlich den echten Dateiinhalt (getarnte Dateien fallen durch). + +**Files:** +- Create: `src/Integrations/Media/MediaImporter.php`, `src/Integrations/Media/MediaUploadFromUrlAbility.php`, `src/Integrations/Media/MediaUploadAbility.php` +- Modify: `src/Integrations/Media/MediaIntegration.php`, `tests/Unit/Integrations/Media/MediaIntegrationTest.php` +- Test: `tests/Unit/Integrations/Media/MediaImporterTest.php`, `tests/Unit/Integrations/Media/MediaUploadAbilitiesTest.php` + +**Interfaces:** +- Consumes: `AdminIncludes::load()` (Task 3), `MediaPresenter::summary()/summarySchema()` (Task 1), `Limits`, `ChangeLog`, `ToolResult`, `Errors`. +- Produces: + - `MediaImporter` (nicht final): `MIMES` (Endungen → MIME: jpg/jpeg/jpe, png, gif, webp, avif, pdf, mp4/m4v, mp3; Filter `lit_mcp_upload_mimes`); `__construct(Limits $limits)`; `fromUrl(string $url, ?string $filename, array $meta): int|WP_Error`; `fromBase64(string $data, string $filename, array $meta): int|WP_Error` (`$meta` = `{title?, alt?, post_id?}`); löscht die temporäre Datei bei jedem Fehler. + - `MediaUploadFromUrlAbility(MediaImporter $importer, MediaPresenter $presenter, ChangeLog $log)` – `lit/media-upload-from-url`, Input `{url, filename?, title?, alt?, post_id?}`; direkt; Ergebnis-Extra `attachment` (summary). + - `MediaUploadAbility(MediaImporter $importer, MediaPresenter $presenter, ChangeLog $log, Limits $limits)` – `lit/media-upload`, Input `{filename, data (Base64), title?, alt?, post_id?}`; indirekt; Ergebnis-Extra `attachment`. + - Beide: Recht `upload_files`, bei `post_id` zusätzlich `edit_post` für diesen Eintrag; nicht idempotent. + +- [ ] **Step 1: Failing Tests** + +`tests/Unit/Integrations/Media/MediaImporterTest.php`: +```php + */ + private array $tempFiles = []; + + protected function setUp(): void + { + parent::setUp(); + Functions\when('get_option')->justReturn(['upload_mb' => 1]); + Functions\when('wp_tempnam')->alias(function (): string { + $file = (string) tempnam(sys_get_temp_dir(), 'lit-up'); + $this->tempFiles[] = $file; + return $file; + }); + Functions\when('wp_delete_file')->alias(static function (string $file): void { + if (is_file($file)) { + unlink($file); + } + }); + Functions\when('sanitize_file_name')->returnArg(); + } + + protected function tearDown(): void + { + foreach ($this->tempFiles as $file) { + if (is_file($file)) { + unlink($file); + } + } + parent::tearDown(); + } + + public function testBase64UploadIsSideloadedWithAltText(): void + { + Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => 'png', 'type' => 'image/png', 'proper_filename' => false]); + Functions\expect('media_handle_sideload')->once() + ->with(\Mockery::on(static fn (array $file): bool => $file['name'] === 'logo.png' && is_file($file['tmp_name'])), 5, 'Logo') + ->andReturn(55); + Functions\expect('update_post_meta')->once()->with(55, '_wp_attachment_image_alt', 'Firmenlogo'); + + $id = (new MediaImporter(new Limits()))->fromBase64(base64_encode('PNGDATA'), 'logo.png', [ + 'title' => 'Logo', + 'alt' => 'Firmenlogo', + 'post_id' => 5, + ]); + + self::assertSame(55, $id); + } + + public function testOversizedBase64IsRejectedBeforeDecoding(): void + { + $result = (new MediaImporter(new Limits()))->fromBase64(str_repeat('A', 2 * 1024 * 1024), 'big.png', []); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertStringContainsString('Limit 1 MB', $result->get_error_message()); + self::assertSame([], $this->tempFiles, 'no temp file may be written for an oversized upload'); + } + + public function testInvalidBase64IsRejected(): void + { + $result = (new MediaImporter(new Limits()))->fromBase64('***', 'x.png', []); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + + public function testDisguisedFileIsRejectedAndTheTempFileRemoved(): void + { + Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => false, 'type' => false, 'proper_filename' => false]); + Functions\expect('media_handle_sideload')->never(); + + $result = (new MediaImporter(new Limits()))->fromBase64(base64_encode('get_error_code()); + foreach ($this->tempFiles as $file) { + self::assertFileDoesNotExist($file); + } + } + + public function testInternalUrlsAreRejectedWithoutRequest(): void + { + Functions\when('wp_http_validate_url')->justReturn(false); + Functions\expect('download_url')->never(); + + $result = (new MediaImporter(new Limits()))->fromUrl('http://127.0.0.1/wp-config.php', null, []); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + + public function testFailedDownloadIsReported(): void + { + Functions\when('wp_http_validate_url')->returnArg(); + Functions\when('download_url')->justReturn(new WP_Error('http_404', 'Not Found')); + + $result = (new MediaImporter(new Limits()))->fromUrl('https://example.com/fehlt.jpg', null, []); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertStringContainsString('Not Found', $result->get_error_message()); + } +} +``` + +`tests/Unit/Integrations/Media/MediaUploadAbilitiesTest.php`: +```php +allows('summary')->andReturn(['id' => 55, 'title' => 'Logo']); + + return $presenter; + } + + public function testUploadFromUrlReportsTheNewAttachment(): void + { + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 55, 'post_type' => 'attachment', 'post_title' => 'Logo'])); + Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=55&action=edit'); + $importer = Mockery::mock(MediaImporter::class); + $importer->expects('fromUrl')->with('https://example.com/logo.png', null, ['alt' => 'Logo'])->andReturn(55); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->with('lit/media-upload-from-url', 'attachment', '55', Mockery::type('string'))->andReturn(3); + + $result = (new MediaUploadFromUrlAbility($importer, $this->presenter(), $log)) + ->execute(['url' => 'https://example.com/logo.png', 'alt' => 'Logo']); + + self::assertSame(['id' => 55, 'title' => 'Logo'], $result['attachment']); + self::assertSame('created', $result['changed'][0]['action']); + self::assertSame(3, $result['log_id']); + } + + public function testImporterErrorsArePassedThrough(): void + { + $importer = Mockery::mock(MediaImporter::class); + $importer->allows('fromBase64')->andReturn(Errors::invalidInput('zu groß')); + + $result = (new MediaUploadAbility($importer, $this->presenter(), Mockery::mock(ChangeLog::class), new Limits())) + ->execute(['filename' => 'x.png', 'data' => 'AAAA']); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + + public function testPermissionNeedsUploadRightsAndEditRightsOnTheParent(): void + { + Functions\when('current_user_can')->alias(static fn (string $cap, int $id = 0): bool => $cap === 'upload_files'); + $ability = new MediaUploadFromUrlAbility(Mockery::mock(MediaImporter::class), $this->presenter(), Mockery::mock(ChangeLog::class)); + + self::assertTrue($ability->permission(['url' => 'https://example.com/a.png'])); + self::assertSame('lit_forbidden', $ability->permission(['url' => 'https://example.com/a.png', 'post_id' => 5])->get_error_code()); + } + + public function testDescriptionNamesTheConfiguredLimit(): void + { + Functions\when('get_option')->justReturn(['upload_mb' => 8]); + $ability = new MediaUploadAbility(Mockery::mock(MediaImporter::class), $this->presenter(), Mockery::mock(ChangeLog::class), new Limits()); + + self::assertStringContainsString('8 MB', $ability->description()); + self::assertFalse($ability->isDirect()); + } +} +``` + +In `MediaIntegrationTest` die Konstante ersetzen: +```php + private const EXPECTED = [ + 'lit/media-search', + 'lit/media-get', + 'lit/media-preview', + 'lit/media-upload-from-url', + 'lit/media-upload', + 'lit/media-download', + ]; +``` + +- [ ] **Step 2: Run – muss fehlschlagen** + +Run: `composer test -- --filter 'MediaImporterTest|MediaUploadAbilitiesTest|MediaIntegrationTest'` +Expected: FAIL. + +- [ ] **Step 3: Implementierung** + +`src/Integrations/Media/MediaImporter.php`: +```php + 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + 'avif' => 'image/avif', + 'pdf' => 'application/pdf', + 'mp4|m4v' => 'video/mp4', + 'mp3' => 'audio/mpeg', + ]; + + public function __construct(private Limits $limits) + { + } + + /** @param array{title?: string, alt?: string, post_id?: int} $meta */ + public function fromUrl(string $url, ?string $filename, array $meta): int|WP_Error + { + if (! wp_http_validate_url($url)) { + return Errors::invalidInput( + 'Die URL ist nicht erlaubt.', + ['url' => 'nur öffentlich erreichbare http(s)-Adressen'], + 'Eine öffentlich erreichbare Datei-URL angeben (keine internen oder lokalen Adressen).' + ); + } + + AdminIncludes::load(); + $tmp = download_url($url, 30); + if ($tmp instanceof WP_Error) { + return Errors::invalidInput( + 'Download fehlgeschlagen: ' . $tmp->get_error_message(), + ['url' => 'nicht abrufbar'], + 'URL prüfen; nur öffentlich erreichbare Dateien können importiert werden.' + ); + } + + $name = $filename ?? wp_basename((string) wp_parse_url($url, PHP_URL_PATH)); + + return $this->import((string) $tmp, $name, $meta); + } + + /** @param array{title?: string, alt?: string, post_id?: int} $meta */ + public function fromBase64(string $data, string $filename, array $meta): int|WP_Error + { + $estimated = intdiv(strlen($data) * 3, 4); + if ($estimated > $this->limits->uploadBytes() + 2) { + return $this->tooLarge($estimated); + } + + $bytes = base64_decode($data, true); + if ($bytes === false || $bytes === '') { + return Errors::invalidInput( + 'data ist kein gültiges Base64.', + ['data' => 'Base64-kodierter Dateiinhalt erwartet'] + ); + } + + AdminIncludes::load(); + $tmp = (string) wp_tempnam($filename); + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- temp file. + if (file_put_contents($tmp, $bytes) === false) { + wp_delete_file($tmp); + return Errors::internal(); + } + + return $this->import($tmp, $filename, $meta); + } + + /** @param array{title?: string, alt?: string, post_id?: int} $meta */ + private function import(string $tmp, string $filename, array $meta): int|WP_Error + { + $size = (int) filesize($tmp); + if ($size > $this->limits->uploadBytes()) { + wp_delete_file($tmp); + return $this->tooLarge($size); + } + + $mimes = $this->mimes(); + $name = sanitize_file_name($filename !== '' ? $filename : 'datei'); + $check = wp_check_filetype_and_ext($tmp, $name, $mimes); + if (empty($check['type']) || empty($check['ext'])) { + wp_delete_file($tmp); + return Errors::invalidInput( + 'Dateityp nicht erlaubt oder Inhalt passt nicht zur Endung.', + ['filename' => 'erlaubt: ' . implode(', ', array_keys($mimes))], + 'Nur Bilder (jpg, png, gif, webp, avif), PDF, MP4 und MP3 hochladen.' + ); + } + + if (! empty($check['proper_filename'])) { + $name = (string) $check['proper_filename']; + } + + $attachmentId = media_handle_sideload( + ['name' => $name, 'tmp_name' => $tmp], + (int) ($meta['post_id'] ?? 0), + isset($meta['title']) ? (string) $meta['title'] : null + ); + if ($attachmentId instanceof WP_Error) { + wp_delete_file($tmp); + return Errors::invalidInput('WordPress hat die Datei abgelehnt: ' . $attachmentId->get_error_message()); + } + + if (isset($meta['alt']) && $meta['alt'] !== '') { + $alt = sanitize_text_field((string) $meta['alt']); + update_post_meta((int) $attachmentId, '_wp_attachment_image_alt', $alt); + } + + return (int) $attachmentId; + } + + /** @return array */ + private function mimes(): array + { + $mimes = apply_filters('lit_mcp_upload_mimes', self::MIMES); + + return is_array($mimes) && $mimes !== [] ? $mimes : self::MIMES; + } + + private function tooLarge(int $bytes): WP_Error + { + return Errors::invalidInput( + sprintf('Datei zu groß (%.1f MB, Limit %d MB).', $bytes / 1048576, $this->limits->uploadMegabytes()), + ['data' => 'über dem Upload-Limit'], + 'Kleinere Datei verwenden oder das Limit unter Einstellungen → LIT MCP → Limits erhöhen lassen.' + ); + } +} +``` + +`src/Integrations/Media/MediaUploadFromUrlAbility.php`: +```php + 'object', + 'properties' => [ + 'url' => ['type' => 'string', 'minLength' => 1], + 'filename' => ['type' => 'string', 'description' => 'Optional: Dateiname in der Mediathek.'], + 'title' => ['type' => 'string'], + 'alt' => ['type' => 'string'], + 'post_id' => ['type' => 'integer', 'minimum' => 1], + ], + 'required' => ['url'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema(['attachment' => MediaPresenter::summarySchema()], ['attachment']); + } + + 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 + { + if (! current_user_can('upload_files')) { + return Errors::forbidden('Keine Berechtigung zum Hochladen (upload_files).'); + } + + $postId = (int) ($input['post_id'] ?? 0); + + return $postId === 0 || current_user_can('edit_post', $postId) + ? true + : Errors::forbidden(sprintf('Keine Berechtigung, Eintrag %d zu bearbeiten.', $postId)); + } + + public function execute(array $input): mixed + { + $meta = array_intersect_key($input, ['title' => true, 'alt' => true, 'post_id' => true]); + $filename = isset($input['filename']) ? (string) $input['filename'] : null; + $attachmentId = $this->importer->fromUrl((string) $input['url'], $filename, $meta); + if ($attachmentId instanceof WP_Error) { + return $attachmentId; + } + + $attachment = get_post($attachmentId); + $title = $attachment instanceof WP_Post ? (string) $attachment->post_title : ''; + $details = $attachment instanceof WP_Post ? $this->presenter->summary($attachment) : ['id' => $attachmentId]; + $summary = sprintf('Datei "%s" von URL importiert (ID %d)', $title, $attachmentId); + $logId = $this->log->record($this->name(), 'attachment', (string) $attachmentId, $summary); + + return ToolResult::ok($summary . '.') + ->changed((string) $attachmentId, 'attachment', 'created', $title) + ->logId($logId) + ->urls(null, (string) get_edit_post_link($attachmentId, 'raw')) + ->with('attachment', $details) + ->toArray(); + } +} +``` + +`src/Integrations/Media/MediaUploadAbility.php`: +```php +limits->uploadMegabytes() + ); + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'filename' => ['type' => 'string', 'minLength' => 1, 'description' => 'Dateiname mit Endung.'], + 'data' => ['type' => 'string', 'minLength' => 1, 'description' => 'Dateiinhalt als Base64.'], + 'title' => ['type' => 'string'], + 'alt' => ['type' => 'string'], + 'post_id' => ['type' => 'integer', 'minimum' => 1], + ], + 'required' => ['filename', 'data'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema(['attachment' => MediaPresenter::summarySchema()], ['attachment']); + } + + 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 + { + if (! current_user_can('upload_files')) { + return Errors::forbidden('Keine Berechtigung zum Hochladen (upload_files).'); + } + + $postId = (int) ($input['post_id'] ?? 0); + + return $postId === 0 || current_user_can('edit_post', $postId) + ? true + : Errors::forbidden(sprintf('Keine Berechtigung, Eintrag %d zu bearbeiten.', $postId)); + } + + public function execute(array $input): mixed + { + $meta = array_intersect_key($input, ['title' => true, 'alt' => true, 'post_id' => true]); + $attachmentId = $this->importer->fromBase64((string) $input['data'], (string) $input['filename'], $meta); + if ($attachmentId instanceof WP_Error) { + return $attachmentId; + } + + $attachment = get_post($attachmentId); + $title = $attachment instanceof WP_Post ? (string) $attachment->post_title : ''; + $details = $attachment instanceof WP_Post ? $this->presenter->summary($attachment) : ['id' => $attachmentId]; + $summary = sprintf('Datei "%s" hochgeladen (ID %d)', $title, $attachmentId); + $logId = $this->log->record($this->name(), 'attachment', (string) $attachmentId, $summary); + + return ToolResult::ok($summary . '.') + ->changed((string) $attachmentId, 'attachment', 'created', $title) + ->logId($logId) + ->urls(null, (string) get_edit_post_link($attachmentId, 'raw')) + ->with('attachment', $details) + ->toArray(); + } +} +``` + +`src/Integrations/Media/MediaIntegration.php`: `abilities()` ersetzen durch +```php + public function abilities(): array + { + $presenter = new MediaPresenter(); + $finder = new MediaUsageFinder(fn (): array => $this->schema->mediaFieldKeys()); + $importer = new MediaImporter($this->limits); + + return [ + new MediaSearchAbility($presenter, $finder), + new MediaGetAbility($presenter, $finder), + new MediaPreviewAbility(), + new MediaUploadFromUrlAbility($importer, $presenter, $this->log), + new MediaUploadAbility($importer, $presenter, $this->log, $this->limits), + new MediaDownloadAbility($this->limits), + ]; + } +``` + +- [ ] **Step 4: Run – muss bestehen** + +Run: `composer test && composer lint` +Expected: PASS (lange Zeilen umbrechen). + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Media tests/Unit/Integrations/Media +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: media upload by URL and base64 with whitelist and size limit + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- + +### Task 5: `lit/media-update` (Stapel) und `lit/media-delete` (mit Verwendungsprüfung) + +WordPress hat für Medien standardmäßig keinen Papierkorb (`MEDIA_TRASH` = false): `wp_delete_attachment($id, false)` legt nur bei aktivem Medien-Papierkorb in den Papierkorb, sonst löscht es endgültig. Deshalb verlangt das Tool immer `confirm: true` und sagt in der Rückfrage, was passieren wird. + +**Files:** +- Create: `src/Integrations/Media/MediaUpdateAbility.php`, `src/Integrations/Media/MediaDeleteAbility.php` +- Modify: `src/Integrations/Media/MediaIntegration.php`, `tests/Unit/Integrations/Media/MediaIntegrationTest.php` +- Test: `tests/Unit/Integrations/Media/MediaUpdateAbilityTest.php`, `tests/Unit/Integrations/Media/MediaDeleteAbilityTest.php` + +**Interfaces:** +- Consumes: `MediaGuard`, `MediaUsageFinder::usages()` (Task 1), `ChangeLog`, `ToolResult`, `Errors`. +- Produces: + - `MediaUpdateAbility(ChangeLog $log)` – `lit/media-update`, Input `{items: list<{id, alt?, title?, caption?, description?}>}` (1–50); Ergebnis-Extra `results: list<{id, ok, error: ?string, log_id: ?int}>`; indirekt; idempotent; Fehler einzelner Einträge brechen die anderen nicht ab. + - `MediaDeleteAbility(MediaUsageFinder $finder, ChangeLog $log, ?callable $mediaTrash = null)` – `lit/media-delete`, Input `{id, confirm?, force?}`; Ergebnis-Extra `usages`; indirekt; `destructive: true`, nicht idempotent; `changed.action` = `trashed` oder `deleted`. + +- [ ] **Step 1: Failing Tests** + +`tests/Unit/Integrations/Media/MediaUpdateAbilityTest.php`: +```php +alias(static fn (int $id) => $id === 33 + ? new WP_Post(['ID' => 33, 'post_type' => 'attachment', 'post_title' => 'Foto']) + : null); + Functions\when('current_user_can')->justReturn(true); + Functions\expect('wp_update_post')->once()->with(['post_excerpt' => 'Unser Team', 'ID' => 33], true)->andReturn(33); + Functions\expect('update_post_meta')->once()->with(33, '_wp_attachment_image_alt', 'Team vor dem Haus'); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->once()->andReturn(41); + + $result = (new MediaUpdateAbility($log))->execute(['items' => [ + ['id' => 33, 'alt' => 'Team vor dem Haus', 'caption' => 'Unser Team'], + ['id' => 99, 'alt' => 'x'], + ]]); + + self::assertSame(['id' => 33, 'ok' => true, 'error' => null, 'log_id' => 41], $result['results'][0]); + self::assertFalse($result['results'][1]['ok']); + self::assertStringContainsString('nicht gefunden', (string) $result['results'][1]['error']); + self::assertStringContainsString('1 von 2', $result['summary']); + } +} +``` + +`tests/Unit/Integrations/Media/MediaDeleteAbilityTest.php`: +```php + 5, 'title' => 'Team', 'post_type' => 'page', 'where' => 'featured']]; + + protected function setUp(): void + { + parent::setUp(); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 33, 'post_type' => 'attachment', 'post_title' => 'Foto'])); + Functions\when('current_user_can')->justReturn(true); + } + + private function ability(array $usages, ChangeLog $log): MediaDeleteAbility + { + $finder = Mockery::mock(MediaUsageFinder::class); + $finder->allows('usages')->andReturn($usages); + + return new MediaDeleteAbility($finder, $log, static fn (): bool => false); + } + + public function testConfirmationIsRequiredAndMentionsUsages(): void + { + Functions\expect('wp_delete_attachment')->never(); + + $result = $this->ability(self::USAGE, Mockery::mock(ChangeLog::class))->execute(['id' => 33]); + + self::assertSame('lit_confirm_required', $result->get_error_code()); + self::assertStringContainsString('endgültig', $result->get_error_message()); + self::assertSame(self::USAGE, $result->get_error_data()['usages']); + } + + public function testUsedMediaNeedsForce(): void + { + Functions\expect('wp_delete_attachment')->never(); + + $result = $this->ability(self::USAGE, Mockery::mock(ChangeLog::class))->execute(['id' => 33, 'confirm' => true]); + + self::assertSame('lit_in_use', $result->get_error_code()); + } + + public function testForceDeletesUsedMedia(): void + { + Functions\expect('wp_delete_attachment')->once()->with(33, false)->andReturn(new WP_Post(['ID' => 33])); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->with('lit/media-delete', 'attachment', '33', 'Medium "Foto" gelöscht')->andReturn(50); + + $result = $this->ability(self::USAGE, $log)->execute(['id' => 33, 'confirm' => true, 'force' => true]); + + self::assertSame('deleted', $result['changed'][0]['action']); + self::assertSame(self::USAGE, $result['usages']); + } + + public function testIsDestructive(): void + { + $ability = $this->ability([], Mockery::mock(ChangeLog::class)); + + self::assertTrue($ability->annotations()['destructive']); + self::assertFalse($ability->isDirect()); + } +} +``` + +In `MediaIntegrationTest` die Konstante ersetzen: +```php + private const EXPECTED = [ + 'lit/media-search', + 'lit/media-get', + 'lit/media-preview', + 'lit/media-upload-from-url', + 'lit/media-upload', + 'lit/media-download', + 'lit/media-update', + 'lit/media-delete', + ]; +``` + +- [ ] **Step 2: Run – muss fehlschlagen** + +Run: `composer test -- --filter 'MediaUpdateAbilityTest|MediaDeleteAbilityTest|MediaIntegrationTest'` +Expected: FAIL. + +- [ ] **Step 3: Implementierung** + +`src/Integrations/Media/MediaUpdateAbility.php`: +```php + 'post_title', 'caption' => 'post_excerpt', 'description' => 'post_content']; + + public function __construct(private ChangeLog $log) + { + } + + public function name(): string + { + return 'lit/media-update'; + } + + public function label(): string + { + return 'Medien-Metadaten ändern'; + } + + public function description(): string + { + return 'Ändert Alt-Text, Titel, Bildunterschrift und Beschreibung von bis zu 50 Medien in einem Aufruf. ' + . 'Das Ergebnis meldet jeden Eintrag einzeln (results); Fehler einzelner Einträge brechen die ' + . 'anderen nicht ab.'; + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'items' => [ + 'type' => 'array', + 'minItems' => 1, + 'maxItems' => 50, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer', 'minimum' => 1], + 'alt' => ['type' => 'string'], + 'title' => ['type' => 'string'], + 'caption' => ['type' => 'string'], + 'description' => ['type' => 'string'], + ], + 'required' => ['id'], + 'additionalProperties' => false, + ], + ], + ], + 'required' => ['items'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema([ + 'results' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'ok' => ['type' => 'boolean'], + 'error' => ['type' => ['string', 'null']], + 'log_id' => ['type' => ['integer', 'null']], + ], + 'required' => ['id', 'ok', 'error'], + ], + ], + ], ['results']); + } + + 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('upload_files') + ? true + : Errors::forbidden('Keine Berechtigung für die Mediathek (upload_files).'); + } + + public function execute(array $input): mixed + { + $items = is_array($input['items'] ?? null) ? $input['items'] : []; + $results = []; + $changed = []; + $lastLogId = null; + + foreach ($items as $item) { + $item = is_array($item) ? $item : []; + $outcome = $this->updateOne($item); + $results[] = $outcome['result']; + if ($outcome['result']['ok']) { + $changed[] = [(string) $outcome['result']['id'], $outcome['title']]; + $lastLogId = $outcome['result']['log_id']; + } + } + + $result = ToolResult::ok(sprintf('%d von %d Medien aktualisiert.', count($changed), count($items))); + foreach ($changed as [$id, $title]) { + $result->changed($id, 'attachment', 'updated', $title); + } + + return $result->logId($lastLogId)->with('results', $results)->toArray(); + } + + /** + * @param array $item + * @return array{result: array{id: int, ok: bool, error: ?string, log_id: ?int}, title: string} + */ + private function updateOne(array $item): array + { + $id = (int) ($item['id'] ?? 0); + $attachment = MediaGuard::attachment($item['id'] ?? null); + if ($attachment instanceof WP_Error) { + return $this->failure($id, $attachment); + } + + $data = []; + foreach (self::FIELDS as $key => $column) { + if (array_key_exists($key, $item)) { + $data[$column] = (string) $item[$key]; + } + } + + if ($data === [] && ! array_key_exists('alt', $item)) { + return $this->failure($id, Errors::invalidInput('Nichts zu ändern.')); + } + + if ($data !== []) { + $updated = wp_update_post(wp_slash(array_merge($data, ['ID' => $id])), true); + if ($updated instanceof WP_Error) { + return $this->failure($id, Errors::invalidInput($updated->get_error_message())); + } + } + + if (array_key_exists('alt', $item)) { + update_post_meta($id, '_wp_attachment_image_alt', sanitize_text_field((string) $item['alt'])); + } + + $keys = implode(', ', array_keys(array_intersect_key($item, self::FIELDS + ['alt' => true]))); + $logId = $this->log->record( + $this->name(), + 'attachment', + (string) $id, + sprintf('Metadaten von "%s" geändert: %s', $attachment->post_title, $keys) + ); + + return [ + 'result' => ['id' => $id, 'ok' => true, 'error' => null, 'log_id' => $logId], + 'title' => (string) ($item['title'] ?? $attachment->post_title), + ]; + } + + /** @return array{result: array{id: int, ok: bool, error: ?string, log_id: ?int}, title: string} */ + private function failure(int $id, WP_Error $error): array + { + $payload = json_decode($error->get_error_message(), true); + $message = is_array($payload) ? (string) ($payload['message'] ?? '') : $error->get_error_message(); + + return ['result' => ['id' => $id, 'ok' => false, 'error' => $message, 'log_id' => null], 'title' => '']; + } +} +``` + +`src/Integrations/Media/MediaDeleteAbility.php`: +```php +mediaTrash = $mediaTrash ?? static fn (): bool => defined('MEDIA_TRASH') && MEDIA_TRASH + && (! defined('EMPTY_TRASH_DAYS') || EMPTY_TRASH_DAYS); + } + + public function name(): string + { + return 'lit/media-delete'; + } + + public function label(): string + { + return 'Medium löschen'; + } + + public function description(): string + { + return 'Löscht ein Medium (ohne aktivierten Medien-Papierkorb endgültig). Nur nach Rückfrage mit ' + . 'confirm: true; wird das Medium noch verwendet, lehnt das Tool ab, außer force: true.'; + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer', 'minimum' => 1], + 'confirm' => ['type' => 'boolean'], + 'force' => ['type' => 'boolean', 'description' => 'Trotz Verwendung löschen.'], + ], + 'required' => ['id'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema(['usages' => ['type' => 'array', 'items' => ['type' => 'object']]], ['usages']); + } + + 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 + { + $attachment = MediaGuard::attachment($input['id'] ?? null, 'delete_post'); + + return $attachment instanceof WP_Error ? $attachment : true; + } + + public function execute(array $input): mixed + { + $attachment = MediaGuard::attachment($input['id'] ?? null, 'delete_post'); + if ($attachment instanceof WP_Error) { + return $attachment; + } + + $id = (int) $attachment->ID; + $title = (string) $attachment->post_title; + $trash = ($this->mediaTrash)(); + $usages = $this->finder->usages($id); + + if (($input['confirm'] ?? false) !== true) { + $what = $trash ? 'in den Papierkorb legen' : 'endgültig löschen'; + $message = sprintf('Medium "%s" (ID %d) %s?', $title, $id, $what); + if ($usages !== []) { + $message .= sprintf(' Es wird an %d Stelle(n) verwendet.', count($usages)); + } + + return Errors::confirmRequired($message, ['usages' => $usages]); + } + + if ($usages !== [] && ($input['force'] ?? false) !== true) { + return Errors::inUse(sprintf('Medium "%s" wird noch verwendet; nichts wurde gelöscht.', $title), $usages); + } + + $deleted = wp_delete_attachment($id, false); + if (! $deleted) { + return Errors::unsupported('WordPress hat das Löschen abgelehnt.', 'Medium in der Mediathek prüfen.'); + } + + $summary = sprintf('Medium "%s" %s', $title, $trash ? 'in den Papierkorb gelegt' : 'gelöscht'); + $logId = $this->log->record($this->name(), 'attachment', (string) $id, $summary); + + return ToolResult::ok($summary . '.') + ->changed((string) $id, 'attachment', $trash ? 'trashed' : 'deleted', $title) + ->logId($logId) + ->with('usages', $usages) + ->toArray(); + } +} +``` + +`src/Integrations/Media/MediaIntegration.php`: in `abilities()` nach `new MediaDownloadAbility($this->limits),` einfügen: +```php + new MediaUpdateAbility($this->log), + new MediaDeleteAbility($finder, $this->log), +``` + +- [ ] **Step 4: Run – muss bestehen** + +Run: `composer test && composer lint` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Media tests/Unit/Integrations/Media +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: batch media metadata updates and delete with usage check + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- +### Task 6: Expositions-Policy – direkt / indirekt / deaktiviert je Ability + +Aus Etappe 0 offen (a): Welche Abilities direkte Tools, nur über die Meta-Tools erreichbar oder ganz abgeschaltet sind, entscheidet ab jetzt `ExposurePolicy` (Option `lit_mcp_exposure`, gepflegt im Tab „Tools“, Task 10). `Ability::isDirect()` bleibt der Standard. Deaktivierte Abilities werden gar nicht erst bei der Abilities API registriert – damit sind sie weder als Tool noch über `mcp-adapter-execute-ability` erreichbar. `lit/media-preview` liefert Bilder und funktioniert nur direkt; `lit/guide` ist die Einstiegshilfe und lässt sich nicht deaktivieren. + +**Files:** +- Create: `src/Abilities/ExposurePolicy.php` +- Modify: `src/Abilities/AbilityRegistrar.php` (Konstruktor, `registerAbilities()`), `src/Server/ServerRegistrar.php` (Konstruktor, `serverArguments()`), `src/Integrations/IntegrationRegistry.php` (`directAbilityNames()` entfernen), `src/Plugin.php` (`boot()`) +- Test: `tests/Unit/Abilities/ExposurePolicyTest.php`, `tests/Unit/Abilities/AbilityRegistrarTest.php` (Test ergänzen), `tests/Unit/Server/ServerRegistrarTest.php` (Test ergänzen), `tests/Unit/Integrations/IntegrationRegistryTest.php` (Assertion entfernen) + +**Interfaces:** +- Consumes: `Ability::name()/isDirect()`, `IntegrationRegistry::abilities()`. +- Produces: + - `ExposurePolicy` (final): `OPTION = 'lit_mcp_exposure'`; `DIRECT = 'direct'`, `INDIRECT = 'indirect'`, `DISABLED = 'disabled'`, `MODES`; `DIRECT_ONLY = ['lit/media-preview']`; `REQUIRED = ['lit/guide']`; `__construct(array $overrides = [])`; `static fromOption(): self`; `mode(Ability $a): string`; `defaultMode(Ability $a): string`; `enabled(list $abilities): list`; `directNames(list $abilities): list` (Reihenfolge der Registry); `static isAllowed(string $name, string $mode): bool`; `static sanitize(mixed $input): array` (Settings-API-Callback). + - Geänderte Signaturen: `AbilityRegistrar::__construct(IntegrationRegistry $registry, ?ExposurePolicy $policy = null)`; `ServerRegistrar::__construct(IntegrationRegistry $registry, InstructionsBuilder $instructions, ?callable $oauthEnabled = null, ?callable $createAdapterServer = null, ?callable $classExists = null, ?ExposurePolicy $policy = null)` (neuer letzter Parameter; ohne Policy gilt `new ExposurePolicy()` = `isDirect()`). + - Entfernt: `IntegrationRegistry::directAbilityNames()` (einziger Aufrufer war `ServerRegistrar`). + +- [ ] **Step 1: Failing Tests** + +`tests/Unit/Abilities/ExposurePolicyTest.php`: +```php +allows('name')->andReturn($name); + $ability->allows('isDirect')->andReturn($direct); + + return $ability; + } + + public function testDefaultFollowsIsDirect(): void + { + $policy = new ExposurePolicy(); + + self::assertSame('direct', $policy->mode($this->ability('lit/content-get', true))); + self::assertSame('indirect', $policy->mode($this->ability('lit/menu-list', false))); + } + + public function testOverridesChangeTheMode(): void + { + $policy = new ExposurePolicy(['lit/content-get' => 'indirect', 'lit/menu-list' => 'disabled']); + + self::assertSame('indirect', $policy->mode($this->ability('lit/content-get', true))); + self::assertSame('disabled', $policy->mode($this->ability('lit/menu-list', false))); + } + + public function testImpossibleOrBrokenOverridesFallBackToTheDefault(): void + { + $policy = new ExposurePolicy([ + 'lit/media-preview' => 'indirect', + 'lit/guide' => 'disabled', + 'lit/content-get' => 'sometimes', + ]); + + self::assertSame('direct', $policy->mode($this->ability('lit/media-preview', true))); + self::assertSame('direct', $policy->mode($this->ability('lit/guide', true))); + self::assertSame('direct', $policy->mode($this->ability('lit/content-get', true))); + } + + public function testEnabledAndDirectNamesKeepTheRegistryOrder(): void + { + $a = $this->ability('lit/a', true); + $b = $this->ability('lit/b', false); + $c = $this->ability('lit/c', true); + $policy = new ExposurePolicy(['lit/b' => 'direct', 'lit/c' => 'disabled']); + + self::assertSame([$a, $b], $policy->enabled([$a, $b, $c])); + self::assertSame(['lit/a', 'lit/b'], $policy->directNames([$a, $b, $c])); + } + + public function testSanitizeKeepsOnlyValidEntries(): void + { + self::assertSame( + ['lit/content-get' => 'indirect', 'lit/menu-list' => 'disabled'], + ExposurePolicy::sanitize([ + 'lit/menu-list' => 'disabled', + 'lit/content-get' => 'indirect', + 'lit/guide' => 'disabled', + '