`.
+- Read-only kses probe (`kses6.php`): `onmouseover` is removed by Divi and never rendered. No script execution found. Divi renders a `javascript:` `linkUrl` as `href=""`.
+
+**Parity note**
+The same user can store the same markup through core REST (`/wp/v2/posts`) or the code editor. So this is no privilege escalation beyond WordPress core. It is a bypass of the plugin's own, explicitly decided gate. The pipeline guarantee "every path from ability input to post_content goes through ValueCleaner/Composer/TreeEditor" does not hold for `content`.
+
+**Fix proposal**
+- In `PostWriter::prepare()` (create and update), refuse a `content` that contains ``).
+
+### I3 – Info: smaller observations
+
+1. **Multisite super admin bypasses the role gate.** A super admin always has `lit_mcp_use` (`WP_User::has_cap`), even if "Zugriff" allows no role. `AccessTab::currentUserLosesAccess` knows this; the UI text doesn't say it.
+2. **Media usage lists unreadable posts.** `MediaUsageFinder.php:455-461` returns post ID and post type (not the title) for posts the user can't read. This lets a user learn that private posts exist. It is intentional, to block deletes.
+3. **All post types with a UI are exposed.** Every `show_ui` post type, including non-public ones (on DDEV: `path`), is exposed; only core, Divi internals and `lit_mcp_excluded_post_types` are excluded. Consider documenting the filter for data-protection purposes (form submissions, personal data as CPTs).
+4. **Nested REST requests can block later calls.** A `rest_do_request()` to another route during a tool call makes `RequestContext` false for the rest of that HTTP request (`RequestContext.php:281`). This fails closed, so later JSON-RPC calls in the same batch get `forbidden`. Functional only.
+5. **`confirm: true` is not a human confirmation.** Claude sets it itself; under prompt injection it is no safeguard. Keep the destructive tools indirect by default.
+
+---
+
+## Checked and found sound
+
+- **RequestContext:** recomputed on every matched REST request, cleared after our route, and required by every ability. WP-CLI/STDIO and other routes fail closed.
+- **Default mcp-adapter server:** disabled; the only MCP route is `/mcp/mcp-oauth-server` (verified via `rest_get_server()->get_routes()`).
+- **Core Abilities REST:** our abilities have `show_in_rest=false` (only the 3 `core/*` abilities are in REST).
+- **Transport and JWT:** `OAuthHttpTransport` requires a Bearer JWT (HS256 pinned, exp, aud = our route, iss = `home_url()`, app password must still exist). The current user is set only from the JWT; cookie or Basic auth can't reach the tools.
+- **Revocation:**
+ - AccessTab revoke deletes the app password, which the next request's check honours;
+ - refresh-token rotation and reuse detection live in mcp-oauth;
+ - revoke is limited to `edit_user`-able users and, on multisite, to site members.
+- **ConnectionUsage:** records only after a <400 response on our route, and only for `sub === current user`.
+- **`lit_mcp_use` gate:** dynamic cap via `user_has_cap` (no role DB changes); the option is sanitized to existing roles; the filter output is type-checked; the meta-tools' capability filters are set to `lit_mcp_use`.
+- **Exposure:**
+ - disabled and "nur lesen" abilities are never registered, so `execute-ability` answers "not found";
+ - DIRECT_ONLY abilities are `mcp.public=false`, so `execute-ability` refuses them;
+ - `lit/guide` is always direct.
+- **Annotations:** every tool annotated `readonly` really doesn't write (render-summary runs `the_content` side effects only).
+- **Per-object checks:**
+ - `PostGuard::editable` (`edit_post`, managed type, trash rules) on every content, status, revision, render and Divi ability, in both permission and execute;
+ - revisions are checked via the parent and `post_type === 'revision'`;
+ - attachments via `MediaGuard` (`edit_post`/`delete_post`, type check);
+ - menu items via item ID membership in the menu.
+- **Nested IDs:**
+ - featured image, parent and relation fields: `read_post` plus type checks;
+ - menu `object_id`: `read_post`, no private, unpublished only with a flag, taxonomy must be viewable;
+ - Divi media/gallery and `select_post`: `read_post`;
+ - terms: `assign_terms`/`edit_terms`;
+ - meta: `edit_post_meta`/`add_post_meta` per key.
+- **Query and search:**
+ - `content-query`/`media-search`/`replace-text` limit the query to the user's own posts without `edit_others_posts` and gate statuses;
+ - hidden fields can't be used as a query filter.
+- **Settings:**
+ - `manage_options`;
+ - strict allow-list;
+ - `admin_email`/`users_can_register`/`default_role`/`siteurl`/`home` explicitly blocked;
+ - per-key validation (installed languages, valid timezones, published pages, image attachment).
+- **Meta writes:**
+ - `update_post_meta` double-serializes a serialized string, so there is no object injection;
+ - protected keys are refused through the meta caps;
+ - clone and replace copy raw stored meta via `$wpdb` (no unserialize).
+- **Menu URLs:** only http/https/mailto/tel-style schemes, `/path` (not `//` or `/\`) or `#`; control characters and spaces are refused; `esc_url_raw` is applied.
+- **Media files:**
+ - MIME allow-list intersected with the core-allowed types for the user, checked against the real content (`wp_check_filetype_and_ext`);
+ - no SVG by default;
+ - `sanitize_file_name` (no traversal, double extensions defused);
+ - streamed size cap;
+ - Base64 size checked before decoding;
+ - temp files removed on every path;
+ - download and preview only read files `realpath`-inside uploads;
+ - caption and description go through `wp_kses_post`.
+- **Error handling:**
+ - Throwables are mapped to `internal_error`, details only go to the PHP log;
+ - the sideload error is hidden;
+ - no SQL or path text reaches the client (apart from the M3 download messages).
+- **SQL:** every query is `$wpdb->prepare()`d. The only interpolations are table names and the constant status list; the log filters are whitelisted.
+- **Change log:** holds titles and references only, no content. Admin list output is escaped; revision links are gated by `edit_post` on the parent.
+- **Admin UI:**
+ - every admin-post action has `manage_options` plus `check_admin_referer` (revoke nonce per UUID, clear-log);
+ - settings go through `options.php` with `settings_fields` nonces and sanitize callbacks;
+ - `closedpostboxes` is core AJAX with its nonce;
+ - notices and tabs come from fixed maps;
+ - everything is escaped (`esc_js`+`esc_attr` for confirm handlers).
+- **Site Health and admin notices:** static text only, escaped; the loopback check is cached and only runs on demand.
+- **Guides:** topic slug regex plus the glob'd file list (no traversal); filtered guides are site-owned.
+- **Divi pipeline integration:**
+ - the kses window covers only `RevisionPoint::capture` plus the one `wp_update_post` and is restored in `finally`, only when kses was active;
+ - Divi's own `wp_insert_post_data` security filters stay (verified: they strip `on*` attributes);
+ - the insert, update, move, delete and duplicate-node tools only write through Composer/TreeEditor;
+ - duplicate-node adds only the stored label plus a constant;
+ - replace-text re-checks targets per post;
+ - DynamicContentMap drops `loop_*` options and types come from the registry; the fixed rules (enable_html, meta keys, markers) remain.
+- **Uninstall and deactivation:** both remove their own options, transient, cron and table on every site of a network, never posts.
+- **Limits and DoS:**
+ - cursor offset capped at 9 digits, page size ≤100;
+ - batch caps (media-update 50, divi-update 50, insert/delete 20, replace-text 20 targets / dry run ≤100 pages, content-create `divi` 30 sections);
+ - render summary strings bounded;
+ - text search limited to 200 characters.
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',
+ '#is', '', $value);
+ }
+
+ public function code(string $value): string
+ {
+ return $value;
+ }
+
+ public function url(string $value): string
+ {
+ return str_starts_with(strtolower(trim($value)), 'javascript:') ? '' : trim($value);
+ }
+ };
+ }
+
+ public static function media(): MediaResolver
+ {
+ return new class implements MediaResolver {
+ public function image(int $id): ?array
+ {
+ return match ($id) {
+ 33 => ['url' => 'https://kunde.test/wp-content/uploads/team.jpg', 'alt' => 'Unser Team'],
+ 12 => ['url' => 'https://kunde.test/wp-content/uploads/halle.jpg', 'alt' => ''],
+ default => null,
+ };
+ }
+ };
+ }
+
+ public static function placeholders(): Placeholders
+ {
+ return new Placeholders(DesignSystemTest::sample());
+ }
+
+ public static function attrWriter(): AttrWriter
+ {
+ return new AttrWriter(ModuleCatalogTest::sampleCatalog(), self::placeholders(), self::sanitizer());
+ }
+}
+```
+
+`tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php`:
+```php
+ */
+ public static function placeholders(): array
+ {
+ return [
+ 'color alias' => ['{{color:primary}}', '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$'],
+ 'color label' => ['{{ color : Nachtblau }}', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'],
+ 'variable' => ['{{var:Telefon}}', '$variable({"type":"content","value":{"name":"gvid-s1","settings":{}}})$'],
+ 'font' => ['{{font:heading}}', '$variable({"type":"content","value":{"name":"--et_global_heading_font","settings":{}}})$'],
+ 'post title' => [
+ '{{post:title}}',
+ '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$',
+ ],
+ 'featured image' => ['{{post:featured_image}}', '$variable({"type":"content","value":{"name":"post_featured_image","settings":{}}})$'],
+ 'custom field' => [
+ '{{field:telefon_mobil}}',
+ '$variable({"type":"content","value":{"name":"custom_meta_telefon_mobil","settings":{"before":"","after":""}}})$',
+ ],
+ 'plain text' => ['Hallo Welt', 'Hallo Welt'],
+ ];
+ }
+
+ /** @dataProvider placeholders */
+ public function testResolvesToDiviVariables(string $input, string $expected): void
+ {
+ $resolved = ComposeFixture::placeholders()->resolve($input);
+
+ self::assertSame($expected, $resolved);
+ self::assertSame($expected !== $input, Placeholders::isVariable($resolved));
+ }
+
+ /** @return array */
+ public static function invalid(): array
+ {
+ return [
+ 'unknown color' => ['{{color:Lila}}', 'Vorhanden: primary, body, Primärfarbe, Fließtextfarbe, Nachtblau.'],
+ 'unknown variable' => ['{{var:Fax}}', 'Vorhanden: Radius, Telefon'],
+ 'unknown font' => ['{{font:mono}}', 'heading, body'],
+ 'unknown post value' => ['{{post:views}}', 'title, excerpt'],
+ 'bad field key' => ['{{field:a b}}', 'Ungültiger Feldschlüssel'],
+ 'embedded' => ['Seite: {{post:title}}', 'allein im Wert'],
+ ];
+ }
+
+ /** @dataProvider invalid */
+ public function testRejectsUnknownNamesWithTheExistingOnes(string $input, string $message): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage($message);
+
+ ComposeFixture::placeholders()->resolve($input);
+ }
+
+ public function testResolvesDeepValues(): void
+ {
+ self::assertSame(
+ ['color' => '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$', 'size' => '20px'],
+ ComposeFixture::placeholders()->resolveDeep(['color' => '{{color:primary}}', 'size' => '20px'])
+ );
+ self::assertFalse(Placeholders::isVariable('$variable(kaputt)$'));
+ }
+}
+```
+
+`tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php`:
+```php
+ [
+ 'innerContent' => ['desktop' => ['value' => 'Alt']],
+ 'decoration' => ['font' => ['font' => [
+ 'desktop' => ['value' => ['headingLevel' => 'h2', 'color' => '#111111']],
+ 'phone' => ['value' => ['size' => '22px']],
+ ]]],
+ ],
+ 'builderVersion' => '5.2.1',
+ ];
+
+ public function testMergesShortAndRawBreakpointsAndKeepsTheRest(): void
+ {
+ $result = ComposeFixture::attrWriter()->apply('divi/heading', self::HEADING, [
+ 'title.decoration.font.font' => [
+ 'desktop' => ['value' => ['headingLevel' => 'h3'], 'hover' => ['color' => '{{color:primary}}']],
+ 'tablet' => ['size' => '26px'],
+ ],
+ 'title.innerContent' => ['desktop' => 'Neu '],
+ ], 'attrs');
+
+ self::assertSame([], $result['errors']);
+ self::assertSame(
+ [
+ 'desktop' => [
+ 'value' => ['headingLevel' => 'h3'],
+ 'hover' => ['color' => '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$'],
+ ],
+ 'phone' => ['value' => ['size' => '22px']],
+ 'tablet' => ['value' => ['size' => '26px']],
+ ],
+ $result['attrs']['title']['decoration']['font']['font']
+ );
+ self::assertSame('Neu ', $result['attrs']['title']['innerContent']['desktop']['value']);
+ self::assertSame('5.2.1', $result['attrs']['builderVersion']);
+ }
+
+ public function testNullRemovesPathsAndStatesAndTopLevelHoverMeansDesktop(): void
+ {
+ $writer = ComposeFixture::attrWriter();
+
+ $removed = $writer->apply('divi/heading', self::HEADING, ['title.decoration.font.font' => null], 'attrs');
+ self::assertArrayNotHasKey('decoration', $removed['attrs']['title']);
+
+ $phoneGone = $writer->apply('divi/heading', self::HEADING, [
+ 'title.decoration.font.font' => ['phone' => ['value' => null], 'hover' => ['color' => '#000000']],
+ ], 'attrs');
+ self::assertSame(
+ ['desktop' => ['value' => ['headingLevel' => 'h2', 'color' => '#111111'], 'hover' => ['color' => '#000000']]],
+ $phoneGone['attrs']['title']['decoration']['font']['font']
+ );
+ }
+
+ public function testUnknownPathsGetSuggestions(): void
+ {
+ $result = ComposeFixture::attrWriter()->apply('divi/heading', self::HEADING, [
+ 'title.decoration.font' => ['desktop' => ['color' => '#fff']],
+ 'title.innerContent' => ['mobile' => 'x'],
+ ], 'changes[0].attrs');
+
+ self::assertSame(['title.decoration.font'], $result['invalid_paths']);
+ self::assertStringContainsString(
+ 'title.decoration.font.font',
+ $result['errors']['changes[0].attrs.title.decoration.font']
+ );
+ self::assertStringContainsString('unbekannter Breakpoint "mobile"', $result['errors']['changes[0].attrs.title.innerContent']);
+ self::assertSame(self::HEADING, $result['attrs']);
+ }
+
+ public function testSanitizesByPlace(): void
+ {
+ $writer = ComposeFixture::attrWriter();
+
+ self::assertSame('', $writer->cleanValue('divi/button', 'button.innerContent', 'javascript:alert(1)', 'linkUrl'));
+ self::assertSame('x
', $writer->cleanValue('divi/text', 'content.innerContent', 'x
'));
+ self::assertSame('', $writer->cleanValue('divi/code', 'content.innerContent', ''));
+ self::assertSame('10px', $writer->cleanValue('divi/text', 'module.decoration.spacing', '10px '));
+ self::assertSame(
+ '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$',
+ $writer->cleanValue('divi/text', 'module.decoration.background', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$')
+ );
+ self::assertSame(['desktop' => ['value' => 'x']], AttrWriter::normalize(['desktop' => 'x']));
+ }
+}
+```
+
+- [ ] **Step 2: Test laufen lassen (rot)**
+
+Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'PlaceholdersTest|AttrWriterTest'`
+Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\Compose\Placeholders" not found`.
+
+- [ ] **Step 3: Implementierung**
+
+`src/Integrations/Divi/Compose/Placeholders.php`:
+```php
+}}, {{var:}}, {{font:heading|body}}, {{post:title|…}},
+ * {{field:}}. A placeholder must be the whole value; unknown names fail with the
+ * list of existing ones. Pure PHP.
+ */
+final class Placeholders
+{
+ /** {{post:}} => Divi dynamic content option. */
+ public const POST = [
+ 'title' => 'post_title',
+ 'excerpt' => 'post_excerpt',
+ 'date' => 'post_date',
+ 'modified' => 'post_modified_date',
+ 'author' => 'post_author',
+ 'url' => 'post_link_url',
+ 'featured_image' => 'post_featured_image',
+ 'categories' => 'post_categories',
+ 'tags' => 'post_tags',
+ ];
+
+ /** Dynamic content that is text and takes before/after settings. */
+ private const TEXT_SETTINGS = [
+ 'post_title', 'post_excerpt', 'post_date', 'post_modified_date', 'post_author', 'post_categories', 'post_tags',
+ ];
+
+ private const PATTERN = '/^\{\{\s*(color|var|font|post|field)\s*:\s*([^{}]+?)\s*\}\}$/';
+
+ public function __construct(private DesignSystem $design)
+ {
+ }
+
+ /** Whether $value is a Divi variable reference ($variable({…})$), e.g. from an earlier read. */
+ public static function isVariable(string $value): bool
+ {
+ return preg_match('/^\$variable\((\{.*\})\)\$$/s', $value, $match) === 1
+ && is_array(json_decode($match[1], true));
+ }
+
+ /**
+ * Resolves $value when it is a placeholder; other strings are returned unchanged.
+ *
+ * @throws InvalidArgumentException With a German message for the client.
+ */
+ public function resolve(string $value): string
+ {
+ if (preg_match(self::PATTERN, trim($value), $match) !== 1) {
+ if (str_contains($value, '{{')) {
+ throw new InvalidArgumentException(
+ 'Platzhalter müssen allein im Wert stehen, z. B. "{{color:primary}}" (bekannt: color, var, font, '
+ . 'post, field).'
+ );
+ }
+
+ return $value;
+ }
+
+ [, $kind, $name] = $match;
+
+ return match ($kind) {
+ 'color' => $this->color($name),
+ 'var' => $this->variable($name),
+ 'font' => $this->font($name),
+ 'post' => $this->post($name),
+ default => $this->field($name),
+ };
+ }
+
+ /**
+ * Applies resolve() to every string inside $value (full-form values are nested).
+ *
+ * @throws InvalidArgumentException
+ */
+ public function resolveDeep(mixed $value): mixed
+ {
+ if (is_string($value)) {
+ return $this->resolve($value);
+ }
+
+ if (is_array($value)) {
+ return array_map([$this, 'resolveDeep'], $value);
+ }
+
+ return $value;
+ }
+
+ private function color(string $name): string
+ {
+ $id = $this->design->colorId($name);
+ if ($id === null) {
+ $ids = array_column($this->design->colors(), 'id');
+ $known = array_merge(
+ array_keys(array_intersect(DesignSystem::COLOR_ALIASES, $ids)),
+ array_column($this->design->colors(), 'label')
+ );
+ throw new InvalidArgumentException(sprintf(
+ 'Unbekannte globale Farbe "%s". Vorhanden: %s.',
+ $name,
+ implode(', ', array_unique($known))
+ ));
+ }
+
+ return self::variableString('color', $id, new stdClass());
+ }
+
+ private function variable(string $name): string
+ {
+ $variable = $this->design->variable($name);
+ if ($variable === null) {
+ $labels = array_column($this->design->variables(), 'label');
+ throw new InvalidArgumentException(sprintf(
+ 'Unbekannte globale Variable "%s". Vorhanden: %s.',
+ $name,
+ $labels === [] ? '(keine)' : implode(', ', $labels)
+ ));
+ }
+
+ $type = $variable['type'] === 'colors' ? 'color' : 'content';
+
+ return self::variableString($type, $variable['id'], new stdClass());
+ }
+
+ private function font(string $name): string
+ {
+ $id = DesignSystem::FONT_VARIABLES[strtolower($name)] ?? null;
+ if ($id === null) {
+ throw new InvalidArgumentException(
+ sprintf('Unbekannte Theme-Schrift "%s". Vorhanden: heading, body.', $name)
+ );
+ }
+
+ return self::variableString('content', $id, new stdClass());
+ }
+
+ private function post(string $name): string
+ {
+ $option = self::POST[strtolower($name)] ?? null;
+ if ($option === null) {
+ throw new InvalidArgumentException(sprintf(
+ 'Unbekannter Beitragswert "%s". Vorhanden: %s.',
+ $name,
+ implode(', ', array_keys(self::POST))
+ ));
+ }
+
+ $settings = in_array($option, self::TEXT_SETTINGS, true) ? ['before' => '', 'after' => ''] : new stdClass();
+
+ return self::variableString('content', $option, $settings);
+ }
+
+ private function field(string $name): string
+ {
+ if (preg_match('/^[A-Za-z0-9_-]+$/', $name) !== 1) {
+ throw new InvalidArgumentException(sprintf('Ungültiger Feldschlüssel "%s".', $name));
+ }
+
+ return self::variableString('content', 'custom_meta_' . $name, ['before' => '', 'after' => '']);
+ }
+
+ /** @param array|stdClass $settings */
+ private static function variableString(string $type, string $name, array|stdClass $settings): string
+ {
+ $payload = ['type' => $type, 'value' => ['name' => $name, 'settings' => $settings]];
+
+ return '$variable(' . Json::encode($payload) . ')$';
+ }
+}
+```
+
+`src/Integrations/Divi/Compose/ValueSanitizer.php`:
+```php
+ breakpoint map. Paths are checked
+ * against the catalog (invalid_attr_path with suggestions), placeholders resolved, strings
+ * sanitized. A breakpoint may be given short ({"desktop": "#fff"}) or raw
+ * ({"desktop": {"value": "#fff", "hover": "#000"}}); top-level "hover"/"sticky" mean desktop.
+ * Given breakpoints/states replace the existing ones, others stay; null removes. Pure PHP.
+ */
+final class AttrWriter
+{
+ private const STATES = ['value', 'hover', 'sticky'];
+
+ /** innerContent sub keys that hold URLs. */
+ private const URL_KEY = '/(url|src|link)$/i';
+
+ private const CODE_MODULES = ['divi/code', 'divi/fullwidth-code'];
+
+ public function __construct(
+ private ModuleCatalog $catalog,
+ private Placeholders $placeholders,
+ private ValueSanitizer $sanitizer
+ ) {
+ }
+
+ /**
+ * @param array $attrs Current attributes of the block.
+ * @param array $changes Path => breakpoint map, or null to remove the path.
+ * @return array{attrs: array, errors: array, invalid_paths: list}
+ */
+ public function apply(string $module, array $attrs, array $changes, string $field): array
+ {
+ $errors = [];
+ $invalid = [];
+ foreach ($changes as $path => $map) {
+ $path = (string) $path;
+ $key = $field . '.' . $path;
+ if (! $this->catalog->isKnownPath($module, $path)) {
+ $suggestions = $this->catalog->suggestions($module, $path);
+ $errors[$key] = 'unbekannter Attributpfad'
+ . ($suggestions === [] ? '' : '; ähnlich: ' . implode(', ', $suggestions));
+ $invalid[] = $path;
+ continue;
+ }
+
+ if ($map === null) {
+ $attrs = AttrMap::remove($attrs, $path);
+ continue;
+ }
+
+ try {
+ $normalized = $this->clean($module, $path, $this->placeholders->resolveDeep(self::normalize($map)));
+ } catch (InvalidArgumentException $e) {
+ $errors[$key] = $e->getMessage();
+ continue;
+ }
+
+ $merged = self::merge(AttrMap::get($attrs, $path), $normalized);
+ $attrs = $merged === [] ? AttrMap::remove($attrs, $path) : AttrMap::set($attrs, $path, $merged);
+ }
+
+ return ['attrs' => $attrs, 'errors' => $errors, 'invalid_paths' => $invalid];
+ }
+
+ /**
+ * Cleans one value written to $path (used by the short form too).
+ *
+ * @throws InvalidArgumentException
+ */
+ public function cleanValue(string $module, string $path, mixed $value, ?string $subKey = null): mixed
+ {
+ if (is_array($value)) {
+ $clean = [];
+ foreach ($value as $key => $item) {
+ $clean[$key] = $this->cleanValue($module, $path, $item, is_string($key) ? $key : $subKey);
+ }
+
+ return $clean;
+ }
+
+ if (! is_string($value) || Placeholders::isVariable($value)) {
+ return $value;
+ }
+
+ if (! str_ends_with($path, '.innerContent')) {
+ return $this->sanitizer->text($value);
+ }
+
+ if ($subKey !== null && preg_match(self::URL_KEY, $subKey) === 1) {
+ return $this->sanitizer->url($value);
+ }
+
+ return in_array($module, self::CODE_MODULES, true)
+ ? $this->sanitizer->code($value)
+ : $this->sanitizer->html($value);
+ }
+
+ /**
+ * @return array> Breakpoint => state => value.
+ * @throws InvalidArgumentException
+ */
+ public static function normalize(mixed $map): array
+ {
+ $shape = 'erwartet {"desktop": Wert, "tablet": Wert, "phone": Wert, "hover": Wert} '
+ . 'oder {"desktop": {"value": …, "hover": …}}';
+ if (! is_array($map) || $map === [] || array_is_list($map)) {
+ throw new InvalidArgumentException($shape);
+ }
+
+ $normalized = [];
+ foreach ($map as $key => $value) {
+ $key = (string) $key;
+ if ($key === 'hover' || $key === 'sticky') {
+ $normalized['desktop'][$key] = $value;
+ } elseif (in_array($key, AttrMap::BREAKPOINTS, true)) {
+ $isRaw = is_array($value) && $value !== [] && ! array_is_list($value)
+ && array_diff(array_map('strval', array_keys($value)), self::STATES) === [];
+ foreach ($isRaw ? $value : ['value' => $value] as $state => $stateValue) {
+ $normalized[$key][(string) $state] = $stateValue;
+ }
+ } else {
+ throw new InvalidArgumentException(sprintf('unbekannter Breakpoint "%s"; %s', $key, $shape));
+ }
+ }
+
+ return $normalized;
+ }
+
+ /**
+ * @param array> $normalized
+ * @return array>
+ * @throws InvalidArgumentException
+ */
+ private function clean(string $module, string $path, array $normalized): array
+ {
+ foreach ($normalized as $breakpoint => $states) {
+ foreach ($states as $state => $value) {
+ $normalized[$breakpoint][$state] = $this->cleanValue($module, $path, $value);
+ }
+ }
+
+ return $normalized;
+ }
+
+ /**
+ * @param array> $changes
+ * @return array
+ */
+ private static function merge(mixed $existing, array $changes): array
+ {
+ $map = is_array($existing) && AttrMap::isBreakpointMap($existing) ? $existing : [];
+ foreach ($changes as $breakpoint => $states) {
+ $current = is_array($map[$breakpoint] ?? null) ? $map[$breakpoint] : [];
+ foreach ($states as $state => $value) {
+ if ($value === null) {
+ unset($current[$state]);
+ } else {
+ $current[$state] = $value;
+ }
+ }
+
+ if ($current === []) {
+ unset($map[$breakpoint]);
+ } else {
+ $map[$breakpoint] = $current;
+ }
+ }
+
+ return $map;
+ }
+}
+```
+
+- [ ] **Step 4: Tests und Lint (grün)**
+
+Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'PlaceholdersTest|AttrWriterTest' && composer lint`
+Expected: PASS, phpcs ohne Meldungen.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
+git add src/Integrations/Divi/Compose/ tests/Unit/Integrations/Divi/Compose/
+git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi placeholders and full-form attribute writer
+
+Co-Authored-By: Claude Opus 5.5 (1M context)
+Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
+```
+
+---
+
+### Task 2: Kurzform schreiben – `ShortformWriter`
+
+Spec §5.4: Kurzform-Felder nach `ShortformMap` (2a) – Wert je Art prüfen (`onoff` aus true/false, `level` h1–h6, `number`, `enum`, `ids` als „12,33“, `color` nur Farbe oder Platzhalter, `url` ohne `javascript:`), Platzhalter auflösen (nicht beim Code-Modul), bereinigen und unter `.desktop.value[.]` setzen; `null` entfernt; `with` setzt feste Zusatzwerte (Blurb-Bild → `useIcon: off`). Medienfelder (`image_id`, `portrait_id`, `image_ids`) holen URL, ID und Alt-Text über `MediaResolver` – ein ausdrücklich gesetztes `alt` gewinnt.
+
+**Files:**
+- Create: `src/Integrations/Divi/Compose/ShortformWriter.php`
+- Modify: `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` (`shortform()`)
+- Test: `tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php`
+
+**Interfaces:**
+- Consumes: `ShortformMap::fields()` (2a), `AttrMap::set/remove` (2a), `Placeholders`, `ValueSanitizer`, `MediaResolver`, `AttrWriter::cleanValue()` (Task 1).
+- Produces: `ShortformWriter(Placeholders, ValueSanitizer, MediaResolver, AttrWriter)`: `apply(string $type, array $attrs, array $values, string $field): array{attrs, errors}` (`$type` = Kurzname, Fehlerschlüssel `"."`); `ComposeFixture::shortform(): ShortformWriter`.
+
+- [ ] **Step 1: Failing Tests**
+
+In `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` ersetzen:
+```php
+use LIT\MCP\Integrations\Divi\Compose\Placeholders;
+```
+durch:
+```php
+use LIT\MCP\Integrations\Divi\Compose\Placeholders;
+use LIT\MCP\Integrations\Divi\Compose\ShortformWriter;
+```
+
+In `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` (Methode ergänzen) ersetzen:
+```php
+ public static function attrWriter(): AttrWriter
+ {
+ return new AttrWriter(ModuleCatalogTest::sampleCatalog(), self::placeholders(), self::sanitizer());
+ }
+```
+durch:
+```php
+ public static function attrWriter(): AttrWriter
+ {
+ return new AttrWriter(ModuleCatalogTest::sampleCatalog(), self::placeholders(), self::sanitizer());
+ }
+
+ public static function shortform(): ShortformWriter
+ {
+ return new ShortformWriter(self::placeholders(), self::sanitizer(), self::media(), self::attrWriter());
+ }
+```
+
+`tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php`:
+```php
+apply('button', [], [
+ 'text' => 'Termin buchen ',
+ 'url' => '/kontakt',
+ 'new_window' => true,
+ ], 'nodes[0]');
+
+ self::assertSame([], $result['errors']);
+ self::assertSame(
+ ['button' => ['innerContent' => ['desktop' => ['value' => [
+ 'text' => 'Termin buchen', 'linkUrl' => '/kontakt', 'linkTarget' => 'on',
+ ]]]]],
+ $result['attrs']
+ );
+ }
+
+ public function testMediaSetsUrlIdAndAltUnlessAltIsGiven(): void
+ {
+ $writer = ComposeFixture::shortform();
+
+ $image = $writer->apply('image', [], ['image_id' => 33], 'n');
+ self::assertSame(
+ ['src' => 'https://kunde.test/wp-content/uploads/team.jpg', 'id' => '33', 'alt' => 'Unser Team'],
+ $image['attrs']['image']['innerContent']['desktop']['value']
+ );
+
+ $own = $writer->apply('image', [], ['image_id' => 33, 'alt' => 'Team 2026'], 'n');
+ self::assertSame('Team 2026', $own['attrs']['image']['innerContent']['desktop']['value']['alt']);
+
+ $blurb = $writer->apply('blurb', [], ['image_id' => 12, 'title' => 'Halle'], 'n');
+ self::assertSame(
+ ['src' => 'https://kunde.test/wp-content/uploads/halle.jpg', 'useIcon' => 'off'],
+ $blurb['attrs']['imageIcon']['innerContent']['desktop']['value']
+ );
+
+ $gallery = $writer->apply('gallery', [], ['image_ids' => [12, 33]], 'n');
+ self::assertSame('12,33', $gallery['attrs']['image']['advanced']['galleryIds']['desktop']['value']);
+ }
+
+ public function testValidatesKindsAndNames(): void
+ {
+ $result = ComposeFixture::shortform()->apply('heading', [], [
+ 'level' => 'h7',
+ 'text' => '{{color:Lila}}',
+ 'size' => 12,
+ ], 'nodes[1]');
+
+ self::assertSame('h1 bis h6 erwartet', $result['errors']['nodes[1].level']);
+ self::assertStringContainsString('Unbekannte globale Farbe', $result['errors']['nodes[1].text']);
+ self::assertSame('unbekanntes Feld; erlaubt: text, level', $result['errors']['nodes[1].size']);
+
+ $more = ComposeFixture::shortform()->apply('contact-field', [], ['type' => 'date', 'required' => 'ja'], 'f');
+ self::assertStringContainsString('erlaubt: input, email', $more['errors']['f.type']);
+ self::assertSame('true oder false erwartet', $more['errors']['f.required']);
+
+ $media = ComposeFixture::shortform()->apply('image', [], ['image_id' => 999, 'url' => 'javascript:x'], 'm');
+ self::assertStringContainsString('kein Bild aus der Mediathek', $media['errors']['m.image_id']);
+ self::assertSame('ungültige URL', $media['errors']['m.url']);
+
+ $none = ComposeFixture::shortform()->apply('accordion', [], ['title' => 'x'], 'a');
+ self::assertStringContainsString('keine Kurzform', $none['errors']['a.title']);
+ }
+
+ public function testPlaceholdersAndNullRemoval(): void
+ {
+ $writer = ComposeFixture::shortform();
+ $attrs = $writer->apply('heading', [], ['text' => '{{post:title}}', 'level' => 'h2'], 'n')['attrs'];
+
+ self::assertSame(
+ '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$',
+ $attrs['title']['innerContent']['desktop']['value']
+ );
+
+ $removed = $writer->apply('heading', $attrs, ['level' => null], 'n')['attrs'];
+ self::assertArrayNotHasKey('decoration', $removed['title'], 'empty parents are pruned');
+
+ $divider = $writer->apply('divider', [], ['color' => '{{color:Nachtblau}}'], 'n')['attrs'];
+ self::assertStringContainsString('gcid-a1b2', $divider['divider']['advanced']['line']['desktop']['value']['color']);
+ self::assertSame(
+ 'Farbe erwartet, z. B. {{color:primary}} oder #1a1a1a',
+ $writer->apply('divider', [], ['color' => 'blau;}'], 'n')['errors']['n.color']
+ );
+ }
+}
+```
+
+- [ ] **Step 2: Test laufen lassen (rot)**
+
+Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter ShortformWriterTest`
+Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\Compose\ShortformWriter" not found`.
+
+- [ ] **Step 3: Implementierung**
+
+`src/Integrations/Divi/Compose/ShortformWriter.php`:
+```php
+.desktop.value[.]".
+ * null removes a field. Pure PHP.
+ */
+final class ShortformWriter
+{
+ private const COLOR = '/^(#[0-9a-fA-F]{3,8}|rgba?\([0-9.,%\s]+\)|hsla?\([0-9.,%\s]+\)|transparent)$/';
+
+ public function __construct(
+ private Placeholders $placeholders,
+ private ValueSanitizer $sanitizer,
+ private MediaResolver $media,
+ private AttrWriter $attrs
+ ) {
+ }
+
+ /**
+ * @param string $type Short module name ("button").
+ * @param array $attrs Current attributes.
+ * @param array $values Field => value.
+ * @return array{attrs: array, errors: array}
+ */
+ public function apply(string $type, array $attrs, array $values, string $field): array
+ {
+ $fields = ShortformMap::fields($type);
+ $errors = [];
+ foreach ($values as $name => $value) {
+ $name = (string) $name;
+ $spec = $fields[$name] ?? null;
+ if ($spec === null) {
+ $errors[$field . '.' . $name] = $fields === []
+ ? 'dieses Modul hat keine Kurzform; Einstellungen über attrs (Vollform) setzen'
+ : 'unbekanntes Feld; erlaubt: ' . implode(', ', array_keys($fields));
+ continue;
+ }
+
+ try {
+ $attrs = $this->set('divi/' . $type, $attrs, $spec, $value, $values);
+ } catch (InvalidArgumentException $e) {
+ $errors[$field . '.' . $name] = $e->getMessage();
+ }
+ }
+
+ return ['attrs' => $attrs, 'errors' => $errors];
+ }
+
+ /**
+ * @param array $attrs
+ * @param array $spec
+ * @param array $all All values of this call (media must not overwrite an explicit alt).
+ * @return array
+ * @throws InvalidArgumentException
+ */
+ private function set(string $module, array $attrs, array $spec, mixed $value, array $all): array
+ {
+ $base = $spec['path'] . '.desktop.value';
+ if ($value === null) {
+ return AttrMap::remove($attrs, $spec['sub'] === null ? $spec['path'] : $base . '.' . $spec['sub']);
+ }
+
+ if ($spec['kind'] === 'media') {
+ $attrs = $this->media($attrs, $base, $spec['keys'], $value, ! array_key_exists('alt', $all));
+ } else {
+ $clean = $this->value($module, $spec, $value);
+ $attrs = AttrMap::set($attrs, $spec['sub'] === null ? $base : $base . '.' . $spec['sub'], $clean);
+ }
+
+ foreach ($spec['with'] ?? [] as $sub => $fixed) {
+ $attrs = AttrMap::set($attrs, $base . '.' . $sub, $fixed);
+ }
+
+ return $attrs;
+ }
+
+ /**
+ * @param array $attrs
+ * @param array{0: string, 1: ?string, 2: ?string} $keys URL key, ID key, alt key.
+ * @return array
+ * @throws InvalidArgumentException
+ */
+ private function media(array $attrs, string $base, array $keys, mixed $value, bool $withAlt): array
+ {
+ $id = is_int($value) ? $value : (is_string($value) && ctype_digit($value) ? (int) $value : 0);
+ $image = $id > 0 ? $this->media->image($id) : null;
+ if ($image === null) {
+ throw new InvalidArgumentException(
+ 'kein Bild aus der Mediathek; Anhang-ID z. B. mit lit-media-search suchen'
+ );
+ }
+
+ [$urlKey, $idKey, $altKey] = $keys;
+ $attrs = AttrMap::set($attrs, $base . '.' . $urlKey, $this->sanitizer->url($image['url']));
+ if ($idKey !== null) {
+ $attrs = AttrMap::set($attrs, $base . '.' . $idKey, (string) $id);
+ }
+
+ if ($altKey !== null && $withAlt && $image['alt'] !== '') {
+ $attrs = AttrMap::set($attrs, $base . '.' . $altKey, $this->sanitizer->text($image['alt']));
+ }
+
+ return $attrs;
+ }
+
+ /**
+ * @param array $spec
+ * @throws InvalidArgumentException
+ */
+ private function value(string $module, array $spec, mixed $value): mixed
+ {
+ switch ($spec['kind']) {
+ case 'onoff':
+ if (is_bool($value)) {
+ return $value ? 'on' : 'off';
+ }
+ if ($value === 'on' || $value === 'off') {
+ return $value;
+ }
+ throw new InvalidArgumentException('true oder false erwartet');
+ case 'level':
+ if (is_string($value) && preg_match('/^h[1-6]$/', $value) === 1) {
+ return $value;
+ }
+ throw new InvalidArgumentException('h1 bis h6 erwartet');
+ case 'number':
+ if (is_int($value) || is_float($value) || (is_string($value) && is_numeric($value))) {
+ return $value + 0;
+ }
+ throw new InvalidArgumentException('Zahl erwartet');
+ case 'enum':
+ if (is_string($value) && in_array($value, $spec['values'], true)) {
+ return $value;
+ }
+ throw new InvalidArgumentException('erlaubt: ' . implode(', ', $spec['values']));
+ case 'ids':
+ return $this->ids($value);
+ }
+
+ if (! is_string($value) && ! is_int($value) && ! is_float($value)) {
+ throw new InvalidArgumentException('Text erwartet');
+ }
+
+ $value = (string) $value;
+ if ($spec['kind'] !== 'code') {
+ $value = $this->placeholders->resolve($value);
+ if (Placeholders::isVariable($value)) {
+ return $value;
+ }
+ }
+
+ if ($spec['kind'] === 'color') {
+ if (preg_match(self::COLOR, trim($value)) !== 1) {
+ throw new InvalidArgumentException('Farbe erwartet, z. B. {{color:primary}} oder #1a1a1a');
+ }
+
+ return $this->sanitizer->text($value);
+ }
+
+ if ($spec['kind'] === 'url') {
+ $url = $this->sanitizer->url($value);
+ if ($url === '' && trim($value) !== '') {
+ throw new InvalidArgumentException('ungültige URL');
+ }
+
+ return $url;
+ }
+
+ return match ($spec['kind']) {
+ 'html', 'code' => $this->attrs->cleanValue($module, (string) $spec['path'], $value),
+ default => $this->sanitizer->text($value),
+ };
+ }
+
+ /** @throws InvalidArgumentException */
+ private function ids(mixed $value): string
+ {
+ if (! is_array($value) || $value === [] || ! array_is_list($value)) {
+ throw new InvalidArgumentException('Liste von Anhang-IDs erwartet, z. B. [12, 33]');
+ }
+
+ $ids = [];
+ foreach ($value as $id) {
+ $id = is_int($id) ? $id : (is_string($id) && ctype_digit($id) ? (int) $id : 0);
+ if ($id <= 0 || $this->media->image($id) === null) {
+ throw new InvalidArgumentException(sprintf('%s ist kein Bild aus der Mediathek', (string) $id));
+ }
+ $ids[] = $id;
+ }
+
+ return implode(',', $ids);
+ }
+}
+```
+
+- [ ] **Step 4: Tests und Lint (grün)**
+
+Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter Compose && composer lint`
+Expected: PASS, phpcs ohne Meldungen.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
+git add src/Integrations/Divi/Compose/ShortformWriter.php tests/Unit/Integrations/Divi/Compose/
+git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi short-form writer
+
+Co-Authored-By: Claude Opus 5.5 (1M context)
+Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
+```
+
+---
+
+### Task 3: Aus Beschreibungen Blöcke bauen – `StructureRules` und `Composer`
+
+Spec §5.4 „Struktur“ und §5.5: Abschnitt → Zeilen (`"columns": "1_3,2_3"`) → Spalten → Module; Vollbreite über `section_type: "fullwidth"`; Kind-Module in `items`; `preset` per Name (Modul-Presets aus dem Designsystem); fehlendes `label` → „ – “; `heading` ohne `level` → `h2` (D9); jeder neue Block bekommt `builderVersion` (live `ET_BUILDER_PRODUCT_VERSION`). Spaltenbreiten über `module.decoration.sizing.desktop.value.flexType` wie Divi 5.2 (D8; `a_b` mit 24 % b = 0 → `(a·24/b)_24`, sonst Fünftel und 1/7, 1/9, 1/10, 1/11; Summe = 1). `StructureRules` prüft, was wohin darf (Seite → Abschnitte, regulärer Abschnitt → Zeilen, Vollbreite → `fullwidth-*`, Spalte/Gruppe → Module, Elternmodul → seine Kind-Module; Spalten nur mit ihrer Zeile; Spezialabschnitte und nur lesbare Knoten nicht). Alle Fehler werden mit Eingabepfad gesammelt. `update()` wendet `set`/`attrs`/`preset`/`label` auf einen vorhandenen Block an (für `lit-divi-update`).
+
+**Files:**
+- Create: `src/Integrations/Divi/Compose/{StructureRules,Composer}.php`
+- Modify: `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` (`rules()`, `composer()`)
+- Test: `tests/Unit/Integrations/Divi/Compose/ComposerTest.php`
+
+**Interfaces:**
+- Consumes: `Block::create()/insertChild()/name()/attrs()/isDivi()/isOpaque()`, `AttrMap::set/desktop` (2a), `ModuleCatalog::has/category/childrenOf/title/normalize` (2a), `ShortformMap::fields()` (2a), `DesignSystem::modulePresetId/modulePresetNames` (2a), `ShortformWriter`, `AttrWriter` (Tasks 1–2).
+- Produces:
+ - `StructureRules(ModuleCatalog)`: `violation(?Block $parent, string $child): ?string` (null = erlaubt; `$parent` null = oberste Ebene); `isPlainModule(string $name): bool`.
+ - `Composer(ModuleCatalog, StructureRules, ShortformWriter, AttrWriter, DesignSystem, string $builderVersion)`: `compose(array $specs, ?Block $parent, string $field, string $context = ''): array{blocks: list, errors: array, invalid_paths: list}` (Fehlerschlüssel z. B. `nodes[0].rows[1].modules[0][2].text`); `update(Block $block, array $change, string $field): array{attrs, errors, invalid_paths}`; `autoLabel(string $name, string $context): string`.
+ - `ComposeFixture::rules()`, `ComposeFixture::composer()` (Builder-Version `5.2.1`).
+
+- [ ] **Step 1: Failing Tests**
+
+In `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` ersetzen:
+```php
+use LIT\MCP\Integrations\Divi\Compose\AttrWriter;
+```
+durch:
+```php
+use LIT\MCP\Integrations\Divi\Compose\AttrWriter;
+use LIT\MCP\Integrations\Divi\Compose\Composer;
+```
+
+In `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` ersetzen:
+```php
+use LIT\MCP\Integrations\Divi\Compose\ShortformWriter;
+```
+durch:
+```php
+use LIT\MCP\Integrations\Divi\Compose\ShortformWriter;
+use LIT\MCP\Integrations\Divi\Compose\StructureRules;
+```
+
+In `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` (Methoden ergänzen) ersetzen:
+```php
+ public static function shortform(): ShortformWriter
+ {
+ return new ShortformWriter(self::placeholders(), self::sanitizer(), self::media(), self::attrWriter());
+ }
+}
+```
+durch:
+```php
+ public static function shortform(): ShortformWriter
+ {
+ return new ShortformWriter(self::placeholders(), self::sanitizer(), self::media(), self::attrWriter());
+ }
+
+ public static function rules(): StructureRules
+ {
+ return new StructureRules(ModuleCatalogTest::sampleCatalog());
+ }
+
+ public static function composer(): Composer
+ {
+ return new Composer(
+ ModuleCatalogTest::sampleCatalog(),
+ self::rules(),
+ self::shortform(),
+ self::attrWriter(),
+ DesignSystemTest::sample(),
+ '5.2.1'
+ );
+ }
+}
+```
+
+`tests/Unit/Integrations/Divi/Compose/ComposerTest.php`:
+```php
+compose([[
+ 'type' => 'section',
+ 'label' => 'Hero',
+ 'background_color' => '{{color:Nachtblau}}',
+ 'rows' => [[
+ 'columns' => '1_3,2_3',
+ 'modules' => [
+ [['type' => 'image', 'image_id' => 33]],
+ [
+ ['type' => 'heading', 'text' => 'Willkommen'],
+ ['type' => 'button', 'text' => 'Termin buchen', 'url' => '/kontakt', 'preset' => 'CTA primär'],
+ ],
+ ],
+ ]],
+ ]], null, 'nodes');
+
+ self::assertSame([], $result['errors']);
+ self::assertStringStartsWith(
+ '',
+ Serializer::block($result['blocks'][0])
+ );
+
+ $section = $result['blocks'][0];
+ [$heading, $button] = $section->children()[0]->children()[1]->children();
+ self::assertSame('16_24', NodeSummary::columnWidth($section->children()[0]->children()[1]));
+ self::assertSame('h2', $heading->attrs()['title']['decoration']['font']['font']['desktop']['value']['headingLevel']);
+ self::assertSame('Hero – Heading', NodeSummary::label($heading));
+ self::assertSame(['p-2'], $button->attrs()['modulePreset']);
+
+ // What the composer writes, the parser reads back unchanged.
+ $markup = Serializer::block($section);
+ self::assertSame($markup, Serializer::document((new Parser())->parse($markup)));
+ self::assertFalse((new Parser())->parse($markup)->children()[0]->isOpaque());
+ }
+
+ public function testShortcutsItemsFullwidthAndFullForm(): void
+ {
+ $result = ComposeFixture::composer()->compose([
+ ['type' => 'section', 'modules' => [
+ ['type' => 'accordion', 'items' => [['title' => 'Frage', 'content' => 'Antwort
', 'open' => true]]],
+ ['type' => 'text', 'content' => 'x
', 'label' => 'Einleitung',
+ 'attrs' => ['module.decoration.spacing' => ['desktop' => ['padding' => ['top' => '20px']]]]],
+ ]],
+ ['type' => 'section', 'section_type' => 'fullwidth', 'modules' => [
+ ['type' => 'fullwidth-header', 'attrs' => ['title.innerContent' => ['desktop' => 'Großer Titel']]],
+ ]],
+ ], null, 'nodes');
+
+ self::assertSame([], $result['errors']);
+ [$regular, $fullwidth] = $result['blocks'];
+ $column = $regular->children()[0]->children()[0];
+ self::assertSame('24_24', NodeSummary::columnWidth($column));
+ [$accordion, $text] = $column->children();
+ self::assertSame('divi/accordion-item', $accordion->children()[0]->name());
+ self::assertSame('on', $accordion->children()[0]->attrs()['module']['advanced']['open']['desktop']['value']);
+ self::assertSame('Einleitung', NodeSummary::label($text));
+ self::assertSame(['top' => '20px'], $text->attrs()['module']['decoration']['spacing']['desktop']['value']['padding']);
+ self::assertSame('fullwidth', $fullwidth->attrs()['module']['advanced']['type']['desktop']['value']);
+ self::assertSame('divi/fullwidth-header', $fullwidth->children()[0]->name());
+ }
+
+ public function testCollectsEveryErrorWithItsInputPath(): void
+ {
+ $result = ComposeFixture::composer()->compose([
+ ['type' => 'button', 'text' => 'x'],
+ ['type' => 'section', 'rows' => [
+ ['columns' => '1_2,1_3', 'modules' => []],
+ ['columns' => '1_2,1_2', 'modules' => [[['type' => 'slider-pro']], [['type' => 'accordion-item']]]],
+ ]],
+ ['type' => 'section', 'preset' => 'Gibt es nicht', 'foo' => 1],
+ ['type' => 'section', 'modules' => [['type' => 'heading', 'attrs' => ['title.fnt' => ['desktop' => 'x']]]]],
+ ], null, 'nodes');
+
+ self::assertSame('auf oberster Ebene sind nur Abschnitte (section) erlaubt', $result['errors']['nodes[0].type']);
+ self::assertStringContainsString('volle Zeile', $result['errors']['nodes[1].rows[0].columns']);
+ self::assertStringContainsString('unbekanntes Modul "divi/slider-pro"', $result['errors']['nodes[1].rows[1].modules[0][0].type']);
+ self::assertStringContainsString('passt nicht in eine Spalte', $result['errors']['nodes[1].rows[1].modules[1][0].type']);
+ self::assertStringContainsString('unbekanntes Preset', $result['errors']['nodes[2].preset']);
+ self::assertStringContainsString('unbekanntes Feld', $result['errors']['nodes[2].foo']);
+ self::assertSame(['title.fnt'], $result['invalid_paths']);
+ self::assertArrayHasKey('nodes[3].modules[0].modules[0][0].attrs.title.fnt', $result['errors']);
+ }
+
+ public function testColumnWidths(): void
+ {
+ $composer = ComposeFixture::composer();
+ $widths = static function (string $columns) use ($composer): array {
+ $result = $composer->compose([['type' => 'section', 'rows' => [['columns' => $columns]]]], null, 'n');
+
+ return [
+ array_map([NodeSummary::class, 'columnWidth'], $result['blocks'][0]->children()[0]->children()),
+ $result['errors'],
+ ];
+ };
+
+ self::assertSame([['12_24', '12_24'], []], $widths('1_2,1_2'));
+ self::assertSame([['6_24', '18_24'], []], $widths('1_4, 3_4'));
+ self::assertSame([['2_5', '3_5'], []], $widths('2_5,3_5'));
+ self::assertSame(['24_24'], $widths('1_7,5_7')[0]);
+ self::assertArrayHasKey('n[0].rows[0].columns', $widths('1_7,5_7')[1]);
+ self::assertArrayHasKey('n[0].rows[0].columns', $widths('abc')[1]);
+ }
+
+ public function testUpdateChangesShortFormPresetAndLabel(): void
+ {
+ $block = Block::create('divi/button', ['button' => ['innerContent' => ['desktop' => ['value' => ['text' => 'Alt']]]]]);
+
+ $result = ComposeFixture::composer()->update($block, [
+ 'set' => ['text' => 'Neu', 'url' => '/neu'],
+ 'preset' => 'Standard',
+ 'label' => 'Hero – CTA',
+ ], 'changes[0]');
+
+ self::assertSame([], $result['errors']);
+ self::assertSame(['text' => 'Neu', 'linkUrl' => '/neu'], $result['attrs']['button']['innerContent']['desktop']['value']);
+ self::assertSame(['p-1'], $result['attrs']['modulePreset']);
+ self::assertSame('Hero – CTA', $result['attrs']['module']['meta']['adminLabel']['desktop']['value']);
+
+ $cleared = ComposeFixture::composer()->update(
+ Block::create('divi/button', $result['attrs']),
+ ['preset' => null],
+ 'changes[0]'
+ );
+ self::assertArrayNotHasKey('modulePreset', $cleared['attrs']);
+ }
+}
+```
+
+- [ ] **Step 2: Test laufen lassen (rot)**
+
+Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter ComposerTest`
+Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\Compose\StructureRules" not found`.
+
+- [ ] **Step 3: Implementierung**
+
+`src/Integrations/Divi/Compose/StructureRules.php`:
+```php
+catalog->has($child)) {
+ return sprintf('unbekanntes Modul "%s" (siehe lit-divi-catalog)', $child);
+ }
+
+ if ($parent === null) {
+ return $child === 'divi/section' ? null : 'auf oberster Ebene sind nur Abschnitte (section) erlaubt';
+ }
+
+ if (! $parent->isDivi() || $parent->isOpaque()) {
+ return 'der Zielknoten ist nur lesbar (globales Modul oder fremder Block)';
+ }
+
+ $parentName = $parent->name();
+ if ($parentName === 'divi/section') {
+ return $this->inSection($parent, $child);
+ }
+
+ if (in_array($parentName, self::ROWS, true)) {
+ return 'Spalten entstehen mit der Zeile ("columns"); eine neue Zeile mit der gewünschten Aufteilung '
+ . 'einfügen';
+ }
+
+ if (in_array($parentName, self::COLUMNS, true) || $parentName === 'divi/group') {
+ return $this->isPlainModule($child) || $child === 'divi/group'
+ ? null
+ : sprintf('%s passt nicht in eine Spalte', $child);
+ }
+
+ $children = $this->catalog->childrenOf($parentName);
+ if ($children === []) {
+ return sprintf('%s kann keine Kind-Module enthalten', $parentName);
+ }
+
+ return in_array($child, $children, true)
+ ? null
+ : sprintf('%s erlaubt nur %s als Kind', $parentName, implode(', ', $children));
+ }
+
+ public function isPlainModule(string $name): bool
+ {
+ return $this->catalog->category($name) === 'module' && ! str_starts_with($name, 'divi/fullwidth-');
+ }
+
+ private function inSection(Block $section, string $child): ?string
+ {
+ $type = AttrMap::desktop($section->attrs(), 'module.advanced.type');
+ if ($type === 'specialty') {
+ return 'Spezialabschnitte (specialty) nur im Divi-Builder umbauen; Module in ihre Spalten einfügen';
+ }
+
+ if ($type === 'fullwidth') {
+ return str_starts_with($child, 'divi/fullwidth-')
+ ? null
+ : 'in einen Vollbreite-Abschnitt passen nur fullwidth-Module';
+ }
+
+ return $child === 'divi/row' ? null : 'ein Abschnitt enthält Zeilen (row)';
+ }
+}
+```
+
+`src/Integrations/Divi/Compose/Composer.php`:
+```php
+ – " (spec §5.5).
+ * Every error is collected with its input path; blocks are only usable when there are none.
+ * Pure PHP.
+ */
+final class Composer
+{
+ private const COMMON = ['type', 'label', 'preset', 'attrs'];
+ private const SECTION = ['section_type', 'background_color', 'rows', 'modules'];
+ private const ROW = ['columns', 'modules'];
+
+ /** Column fractions Divi's flex layout knows besides multiples of 1/24. */
+ private const SPECIAL_WIDTHS = ['1_5', '2_5', '3_5', '4_5', '1_7', '1_9', '1_10', '1_11'];
+
+ /** @var array */
+ private array $errors = [];
+
+ /** @var list */
+ private array $invalidPaths = [];
+
+ public function __construct(
+ private ModuleCatalog $catalog,
+ private StructureRules $rules,
+ private ShortformWriter $shortform,
+ private AttrWriter $attrWriter,
+ private DesignSystem $design,
+ private string $builderVersion
+ ) {
+ }
+
+ /**
+ * @param list $specs Node descriptions.
+ * @param Block|null $parent Block the nodes go into (null = top level of the page).
+ * @param string $field Input path of $specs for error keys, e.g. "nodes".
+ * @param string $context Admin label of the surrounding section ("" if none).
+ * @return array{blocks: list, errors: array, invalid_paths: list}
+ */
+ public function compose(array $specs, ?Block $parent, string $field, string $context = ''): array
+ {
+ $this->errors = [];
+ $this->invalidPaths = [];
+ $blocks = [];
+ foreach (array_values($specs) as $position => $spec) {
+ $block = $this->node($spec, $parent, sprintf('%s[%d]', $field, $position), $context);
+ if ($block !== null) {
+ $blocks[] = $block;
+ }
+ }
+
+ return ['blocks' => $blocks, 'errors' => $this->errors, 'invalid_paths' => $this->invalidPaths];
+ }
+
+ /**
+ * Applies short-form fields, full-form attrs, preset and label to an existing block
+ * (lit-divi-update); errors like compose().
+ *
+ * @param array $change set, attrs, preset, label
+ * @return array{attrs: array, errors: array, invalid_paths: list}
+ */
+ public function update(Block $block, array $change, string $field): array
+ {
+ $this->errors = [];
+ $this->invalidPaths = [];
+ $attrs = $block->attrs();
+ $short = $block->shortName();
+
+ if (isset($change['set'])) {
+ if (! is_array($change['set']) || array_is_list($change['set'])) {
+ $this->errors[$field . '.set'] = 'Objekt mit Kurzform-Feldern erwartet';
+ } else {
+ $result = $this->shortform->apply($short, $attrs, $change['set'], $field . '.set');
+ $attrs = $result['attrs'];
+ $this->errors += $result['errors'];
+ }
+ }
+
+ $attrs = $this->common($block->name(), $attrs, $change, $field, null);
+
+ return ['attrs' => $attrs, 'errors' => $this->errors, 'invalid_paths' => $this->invalidPaths];
+ }
+
+ /** Admin label for a new module: " – " or the title alone. */
+ public function autoLabel(string $name, string $context): string
+ {
+ $title = $this->catalog->title($name);
+
+ return $context === '' ? $title : $context . ' – ' . $title;
+ }
+
+ private function node(mixed $spec, ?Block $parent, string $path, string $context): ?Block
+ {
+ if (! is_array($spec) || array_is_list($spec)) {
+ $this->errors[$path] = 'Objekt mit "type" erwartet';
+
+ return null;
+ }
+
+ $type = $this->typeOf($spec, $parent);
+ if ($type === null) {
+ $this->errors[$path . '.type'] = 'type fehlt';
+
+ return null;
+ }
+
+ $violation = $this->rules->violation($parent, $type);
+ if ($violation !== null) {
+ $this->errors[$path . '.type'] = $violation;
+
+ return null;
+ }
+
+ return match ($type) {
+ 'divi/section' => $this->section($spec, $path),
+ 'divi/row' => $this->row($spec, $path, $context),
+ default => $this->module($type, $spec, $path, $context),
+ };
+ }
+
+ /** @param array $spec */
+ private function typeOf(array $spec, ?Block $parent): ?string
+ {
+ if (is_string($spec['type'] ?? null) && $spec['type'] !== '') {
+ return ModuleCatalog::normalize($spec['type']);
+ }
+
+ $children = $parent === null ? [] : $this->catalog->childrenOf($parent->name());
+
+ return count($children) === 1 ? $children[0] : null;
+ }
+
+ /** @param array $spec */
+ private function section(array $spec, string $path): Block
+ {
+ $this->unknownKeys($spec, array_merge(self::COMMON, self::SECTION), $path);
+ $label = is_string($spec['label'] ?? null) ? trim($spec['label']) : '';
+ $attrs = ['builderVersion' => $this->builderVersion];
+ if (($spec['section_type'] ?? 'regular') === 'fullwidth') {
+ $attrs = AttrMap::set($attrs, 'module.advanced.type', ['desktop' => ['value' => 'fullwidth']]);
+ } elseif (($spec['section_type'] ?? 'regular') !== 'regular') {
+ $this->errors[$path . '.section_type'] = 'regular oder fullwidth erwartet (specialty nur im Divi-Builder)';
+ }
+
+ if (isset($spec['background_color'])) {
+ $attrs = $this->backgroundColor($attrs, $spec['background_color'], $path . '.background_color');
+ }
+
+ $section = Block::create('divi/section', $this->common('divi/section', $attrs, $spec, $path, $label));
+ if (isset($spec['rows']) && isset($spec['modules']) && ($spec['section_type'] ?? 'regular') !== 'fullwidth') {
+ $this->errors[$path] = 'entweder rows oder modules angeben';
+
+ return $section;
+ }
+
+ if (($spec['section_type'] ?? 'regular') === 'fullwidth') {
+ $this->children($section, $spec['modules'] ?? [], $path . '.modules', $label);
+ } elseif (isset($spec['modules'])) {
+ $this->children($section, [['type' => 'row', 'modules' => $spec['modules']]], $path . '.modules', $label);
+ } else {
+ $this->children($section, $spec['rows'] ?? [], $path . '.rows', $label);
+ }
+
+ return $section;
+ }
+
+ /** @param array $spec */
+ private function row(array $spec, string $path, string $context): Block
+ {
+ $this->unknownKeys($spec, array_merge(self::COMMON, self::ROW), $path);
+ $widths = $this->widths($spec['columns'] ?? '4_4', $path . '.columns');
+ $label = $context === '' ? '' : $context . ' – Zeile';
+ $attrs = $this->common('divi/row', ['builderVersion' => $this->builderVersion], $spec, $path, $label);
+ $row = Block::create('divi/row', $attrs);
+
+ $modules = $spec['modules'] ?? [];
+ if (! is_array($modules) || ! array_is_list($modules)) {
+ $this->errors[$path . '.modules'] = 'Liste erwartet (je Spalte eine Liste von Modulen)';
+ $modules = [];
+ }
+
+ // One column: a flat list of modules is accepted.
+ $first = $modules[0] ?? null;
+ if (count($widths) === 1 && is_array($first) && ! array_is_list($first)) {
+ $modules = [$modules];
+ }
+
+ if (count($modules) > count($widths)) {
+ $this->errors[$path . '.modules'] = sprintf(
+ '%d Spalten, aber %d Modul-Listen',
+ count($widths),
+ count($modules)
+ );
+ }
+
+ foreach ($widths as $position => $width) {
+ $column = Block::create('divi/column', AttrMap::set(
+ ['builderVersion' => $this->builderVersion],
+ 'module.decoration.sizing',
+ ['desktop' => ['value' => ['flexType' => $width]]]
+ ));
+ $row->insertChild($position, $column);
+ $list = $modules[$position] ?? [];
+ $listPath = sprintf('%s.modules[%d]', $path, $position);
+ $this->children($column, is_array($list) ? $list : [], $listPath, $context);
+ }
+
+ return $row;
+ }
+
+ /** @param array $spec */
+ private function module(string $name, array $spec, string $path, string $context): Block
+ {
+ $short = substr($name, strlen('divi/'));
+ $fields = array_keys(ShortformMap::fields($short));
+ $hasItems = $this->catalog->childrenOf($name) !== [];
+ $allowed = array_merge(
+ self::COMMON,
+ $fields,
+ $hasItems ? ['items'] : [],
+ $name === 'divi/group' ? ['modules'] : []
+ );
+ $this->unknownKeys($spec, $allowed, $path);
+
+ $values = array_intersect_key($spec, array_flip($fields));
+ if ($name === 'divi/heading' && ! array_key_exists('level', $values)) {
+ $values['level'] = 'h2';
+ }
+
+ $attrs = ['builderVersion' => $this->builderVersion];
+ if ($values !== []) {
+ $result = $this->shortform->apply($short, $attrs, $values, $path);
+ $attrs = $result['attrs'];
+ $this->errors += $result['errors'];
+ }
+
+ $label = is_string($spec['label'] ?? null) ? trim($spec['label']) : $this->autoLabel($name, $context);
+ $block = Block::create($name, $this->common($name, $attrs, $spec, $path, $label));
+
+ if ($hasItems) {
+ $this->children($block, $spec['items'] ?? [], $path . '.items', $context);
+ } elseif ($name === 'divi/group') {
+ $this->children($block, $spec['modules'] ?? [], $path . '.modules', $context);
+ }
+
+ return $block;
+ }
+
+ /**
+ * Preset, full-form attrs and admin label – shared by new and updated blocks.
+ *
+ * @param array $attrs
+ * @param array $spec
+ * @return array
+ */
+ private function common(string $name, array $attrs, array $spec, string $path, ?string $label): array
+ {
+ if (array_key_exists('preset', $spec)) {
+ $attrs = $this->preset($name, $attrs, $spec['preset'], $path . '.preset');
+ }
+
+ if (isset($spec['attrs'])) {
+ if (! is_array($spec['attrs']) || array_is_list($spec['attrs'])) {
+ $this->errors[$path . '.attrs'] = 'Objekt "Attributpfad": {Breakpoint: Wert} erwartet';
+ } else {
+ $result = $this->attrWriter->apply($name, $attrs, $spec['attrs'], $path . '.attrs');
+ $attrs = $result['attrs'];
+ $this->errors += $result['errors'];
+ array_push($this->invalidPaths, ...$result['invalid_paths']);
+ }
+ }
+
+ $label = is_string($spec['label'] ?? null) ? trim($spec['label']) : $label;
+ if ($label !== null && $label !== '') {
+ $attrs = AttrMap::set($attrs, 'module.meta.adminLabel', ['desktop' => ['value' => $this->clean($label)]]);
+ }
+
+ return $attrs;
+ }
+
+ /**
+ * @param array $attrs
+ * @return array
+ */
+ private function preset(string $name, array $attrs, mixed $preset, string $path): array
+ {
+ if ($preset === null || $preset === '') {
+ unset($attrs['modulePreset']);
+
+ return $attrs;
+ }
+
+ $id = is_string($preset) ? $this->design->modulePresetId($name, $preset) : null;
+ if ($id === null) {
+ $names = $this->design->modulePresetNames($name);
+ $this->errors[$path] = sprintf(
+ 'unbekanntes Preset; vorhanden für %s: %s',
+ $name,
+ $names === [] ? '(keine)' : implode(', ', $names)
+ );
+
+ return $attrs;
+ }
+
+ $attrs['modulePreset'] = [$id];
+
+ return $attrs;
+ }
+
+ /**
+ * @param array $attrs
+ * @return array
+ */
+ private function backgroundColor(array $attrs, mixed $color, string $path): array
+ {
+ $result = $this->attrWriter->apply(
+ 'divi/section',
+ $attrs,
+ ['module.decoration.background' => ['desktop' => ['color' => $color]]],
+ $path
+ );
+ foreach ($result['errors'] as $message) {
+ $this->errors[$path] = $message;
+ }
+
+ return $result['attrs'];
+ }
+
+ /** @param mixed $specs */
+ private function children(Block $parent, mixed $specs, string $path, string $context): void
+ {
+ if (! is_array($specs) || ($specs !== [] && ! array_is_list($specs))) {
+ $this->errors[$path] = 'Liste erwartet';
+
+ return;
+ }
+
+ foreach ($specs as $position => $spec) {
+ $child = $this->node($spec, $parent, sprintf('%s[%d]', $path, $position), $context);
+ if ($child !== null) {
+ $parent->insertChild(PHP_INT_MAX, $child);
+ }
+ }
+ }
+
+ /**
+ * "1_3,2_3" → flexTypes ["8_24", "16_24"]; the fractions must add up to one row.
+ *
+ * @return list
+ */
+ private function widths(mixed $columns, string $path): array
+ {
+ $parts = is_string($columns) ? array_map('trim', explode(',', $columns)) : [];
+ $widths = [];
+ $sum = 0.0;
+ foreach ($parts as $part) {
+ $valid = preg_match('/^(\d{1,2})_(\d{1,2})$/', $part, $match) === 1
+ && (int) $match[1] >= 1
+ && (int) $match[1] <= (int) $match[2];
+ if (! $valid) {
+ $this->errors[$path] = 'Spaltenaufteilung wie "1_2,1_2" oder "1_3,2_3" erwartet';
+
+ return ['24_24'];
+ }
+
+ [$numerator, $denominator] = [(int) $match[1], (int) $match[2]];
+ if (24 % $denominator === 0) {
+ $widths[] = ($numerator * 24 / $denominator) . '_24';
+ } elseif (in_array($part, self::SPECIAL_WIDTHS, true)) {
+ $widths[] = $part;
+ } else {
+ $this->errors[$path] = sprintf('Spaltenbreite %s gibt es in Divi nicht', $part);
+
+ return ['24_24'];
+ }
+ $sum += $numerator / $denominator;
+ }
+
+ if ($widths === [] || abs($sum - 1.0) > 0.01) {
+ $this->errors[$path] = 'die Spaltenbreiten müssen zusammen eine volle Zeile ergeben (z. B. "1_3,2_3")';
+
+ return ['24_24'];
+ }
+
+ return $widths;
+ }
+
+ /**
+ * @param array $spec
+ * @param list $allowed
+ */
+ private function unknownKeys(array $spec, array $allowed, string $path): void
+ {
+ foreach (array_diff(array_map('strval', array_keys($spec)), $allowed) as $key) {
+ $this->errors[$path . '.' . $key] = 'unbekanntes Feld; erlaubt: ' . implode(', ', $allowed);
+ }
+ }
+
+ private function clean(string $label): string
+ {
+ try {
+ return (string) $this->attrWriter->cleanValue('divi/section', 'module.meta.adminLabel', $label);
+ } catch (InvalidArgumentException) {
+ return $label;
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Tests und Lint (grün)**
+
+Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter Compose && composer lint`
+Expected: PASS (u. a. die exakte Markup-Erwartung in `testBuildsASectionFromTheShortForm` und dass der Parser das erzeugte Markup unverändert zurückgibt), phpcs ohne Meldungen.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
+git add src/Integrations/Divi/Compose/StructureRules.php src/Integrations/Divi/Compose/Composer.php tests/Unit/Integrations/Divi/Compose/
+git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi composer – short/full form to blocks with structure rules, presets and labels
+
+Co-Authored-By: Claude Opus 5.5 (1M context)
+Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
+```
+
+---
+
+### Task 4: Baum ändern und prüfen – `TreeEditor` und `Warnings`
+
+Spec §5.7: Knoten an `parent` (optional `index`), `before` oder `after` einfügen, verschieben (auch zwischen Zeilen und Abschnitten), entfernen, duplizieren – immer gegen `StructureRules`; ein Knoten kann nicht in seinen eigenen Teilbaum wandern; Verschieben und Löschen erhalten jedes Byte der übrigen Seite (Text zwischen Blöcken bleibt stehen). Spec §5.5: Warnungen nur zu neu erzeugten oder geänderten Knoten (und deren Kindern) – feste Farbe, obwohl eine globale Farbe passt (RGB-Abstand ≤ 24), feste Schrift = Theme-Schrift, Bild ohne Alt-Text (`image`, `blurb`, `slide`), leere Module (Kurzform-Hauptfeld leer, Elternmodul ohne Einträge, leere Struktur), Sprung in der Überschriftenhierarchie (Heading-Standard h1, Blurb h4, CTA h2, ``–`` im Text-Modul), Code-Modul. „Ähnliche Abschnitte“ folgt in Etappe 3 (D6).
+
+**Files:**
+- Create: `src/Integrations/Divi/Compose/{TreeEditor,Warnings}.php`
+- Test: `tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php`, `tests/Unit/Integrations/Divi/Compose/WarningsTest.php`
+
+**Interfaces:**
+- Consumes: `NodeIndex::find/parentOf/root/idOf/entries`, `Container::indexOf/insertChild/removeChild`, `Block::copy()` (2a); `StructureRules` (Task 3); `DesignSystem::colors/fonts/colorPlaceholder`, `ShortformMap::childType/primary/values`, `NodeSummary::textOf`, `AttrMap::desktop`, `Placeholders::isVariable`.
+- Produces:
+ - `TreeEditor(StructureRules)`: `position(NodeIndex $index, array $input): array{container: Container, parent: ?Block, index: int}|string` (Fehlertext); `insert(array $position, list $blocks): ?string`; `move(NodeIndex $index, Block $node, array $position): ?string`; `remove(NodeIndex $index, Block $node): void`; `duplicate(NodeIndex $index, Block $node): Block` (Kopie direkt dahinter).
+ - `Warnings(DesignSystem)`: `collect(NodeIndex $index, list $touched): list` (Format `": "`, IDs nach der Änderung).
+
+- [ ] **Step 1: Failing Tests**
+
+`tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php`:
+```php
+parse((string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/' . $name));
+ }
+
+ public function testResolvesPositions(): void
+ {
+ $document = $this->document();
+ $index = NodeIndex::build($document);
+ $editor = new TreeEditor(ComposeFixture::rules());
+
+ $end = $editor->position($index, []);
+ self::assertSame($index->root(), $end['container']);
+ self::assertNull($end['parent']);
+
+ $inColumn = $editor->position($index, ['parent' => 's0.r0.c1', 'index' => 1]);
+ self::assertSame($index->find('s0.r0.c1'), $inColumn['parent']);
+ self::assertSame(1, $inColumn['index']);
+
+ $after = $editor->position($index, ['after' => 's0.r0.c1.m0']);
+ self::assertSame($index->find('s0.r0.c1'), $after['container']);
+ self::assertSame(1, $after['index']);
+
+ $beforeSection = $editor->position($index, ['before' => 's1']);
+ self::assertNull($beforeSection['parent']);
+ self::assertSame(1, $beforeSection['index']);
+
+ self::assertSame('nur eines von parent, before oder after angeben', $editor->position($index, ['parent' => 's0', 'after' => 's1']));
+ self::assertSame('Knoten "s9" gibt es nicht', $editor->position($index, ['before' => 's9']));
+ }
+
+ public function testInsertChecksTheStructureRules(): void
+ {
+ $document = $this->document();
+ $index = NodeIndex::build($document);
+ $editor = new TreeEditor(ComposeFixture::rules());
+ $button = Block::create('divi/button');
+
+ self::assertSame(
+ 'auf oberster Ebene sind nur Abschnitte (section) erlaubt',
+ $editor->insert($editor->position($index, []), [$button])
+ );
+ self::assertStringContainsString('Zeilen', (string) $editor->insert($editor->position($index, ['parent' => 's0']), [$button]));
+ self::assertStringContainsString('Spalten entstehen mit der Zeile', (string) $editor->insert($editor->position($index, ['parent' => 's0.r0']), [$button]));
+ self::assertStringContainsString('accordion-item', (string) $editor->insert($editor->position($index, ['parent' => 's1.r0.c1.m0']), [$button]));
+
+ self::assertNull($editor->insert($editor->position($index, ['after' => 's0.r0.c1.m0']), [$button]));
+ self::assertSame('divi/button', NodeIndex::build($document)->find('s0.r0.c1.m1')?->name());
+ }
+
+ public function testMoveWithinAndAcrossParents(): void
+ {
+ $document = $this->document();
+ $original = Serializer::document($document);
+ $index = NodeIndex::build($document);
+ $editor = new TreeEditor(ComposeFixture::rules());
+ $button = $index->find('s0.r0.c1.m2');
+
+ self::assertNull($editor->move($index, $button, $editor->position($index, ['before' => 's0.r0.c1.m0'])));
+ $index = NodeIndex::build($document);
+ self::assertSame($button, $index->find('s0.r0.c1.m0'));
+
+ self::assertNull($editor->move($index, $button, $editor->position($index, ['parent' => 's1.r0.c0'])));
+ $index = NodeIndex::build($document);
+ self::assertSame($button, $index->find('s1.r0.c0.m1'));
+ self::assertStringContainsString('Termin buchen', Serializer::document($document));
+ self::assertSame(strlen($original), strlen(Serializer::document($document)), 'moving keeps every byte');
+
+ $section = $index->find('s0');
+ self::assertSame(
+ 'ein Knoten kann nicht in sich selbst verschoben werden',
+ $editor->move($index, $section, $editor->position($index, ['parent' => 's0.r0.c0']))
+ );
+ self::assertNull($editor->move($index, $section, $editor->position($index, [])));
+ self::assertSame($section, NodeIndex::build($document)->find('s1'));
+ }
+
+ public function testRemoveAndDuplicate(): void
+ {
+ $document = $this->document();
+ $index = NodeIndex::build($document);
+ $editor = new TreeEditor(ComposeFixture::rules());
+
+ $copy = $editor->duplicate($index, $index->find('s1.r0.c1.m0'));
+ $index = NodeIndex::build($document);
+ self::assertSame($copy, $index->find('s1.r0.c1.m1'));
+ self::assertSame(Serializer::block($index->find('s1.r0.c1.m0')), Serializer::block($copy));
+
+ $editor->remove($index, $index->find('s0'));
+ self::assertSame('divi/section', NodeIndex::build($document)->find('s0')?->name());
+ self::assertNull(NodeIndex::build($document)->find('s1'));
+ }
+
+ public function testReadOnlyTargetsAndSpecialtySections(): void
+ {
+ $document = $this->document('synthetic-mixed.html');
+ $index = NodeIndex::build($document);
+ $editor = new TreeEditor(ComposeFixture::rules());
+ $text = Block::create('divi/text');
+
+ self::assertStringContainsString('nur lesbar', (string) $editor->insert($editor->position($index, ['parent' => 's1.g1']), [$text]));
+ self::assertStringContainsString('Spezialabschnitte', (string) $editor->insert($editor->position($index, ['parent' => 's0']), [Block::create('divi/row')]));
+ self::assertNull($editor->insert($editor->position($index, ['parent' => 's0.c0']), [$text]));
+ }
+}
+```
+
+`tests/Unit/Integrations/Divi/Compose/WarningsTest.php`:
+```php
+compose([
+ ['type' => 'section', 'label' => 'Test', 'modules' => [
+ ['type' => 'heading', 'text' => 'Titel', 'level' => 'h1'],
+ ['type' => 'heading', 'text' => 'Zu tief', 'level' => 'h4'],
+ ['type' => 'image', 'src' => 'https://kunde.test/a.jpg'],
+ ['type' => 'text', 'content' => ''],
+ ['type' => 'code', 'content' => 'x
'],
+ ['type' => 'accordion'],
+ ['type' => 'button', 'text' => 'Los', 'attrs' => [
+ 'button.decoration.background' => ['desktop' => ['color' => '#2ea3f3']],
+ 'button.decoration.font.font' => ['desktop' => ['family' => 'Open Sans', 'color' => '#abcdef']],
+ ]],
+ ]],
+ ], null, 'nodes');
+ self::assertSame([], $composed['errors']);
+ $document = new Document();
+ foreach ($composed['blocks'] as $block) {
+ $document->append($block);
+ }
+
+ $warnings = (new Warnings(DesignSystemTest::sample()))->collect(NodeIndex::build($document), $composed['blocks']);
+
+ self::assertSame(
+ [
+ 's0.r0.c0.m2: Bild ohne Alt-Text.',
+ 's0.r0.c0.m3: text ist leer (content fehlt).',
+ 's0.r0.c0.m4: Code-Modul verwendet – im Divi-Builder nicht visuell bearbeitbar; nur nutzen, wenn kein '
+ . 'natives Modul passt.',
+ 's0.r0.c0.m5: accordion ohne Einträge.',
+ 's0.r0.c0.m6: feste Farbe #2ea3f3 – globale Farbe "Primärfarbe" ({{color:primary}}) passt.',
+ 's0.r0.c0.m6: feste Schrift Open Sans – {{font:heading}} verwenden.',
+ 's0.r0.c0.m1: Sprung in der Überschriftenhierarchie (h4 nach h1).',
+ ],
+ $warnings
+ );
+
+ self::assertSame([], (new Warnings(DesignSystemTest::sample()))->collect(NodeIndex::build($document), []));
+ }
+}
+```
+
+- [ ] **Step 2: Test laufen lassen (rot)**
+
+Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'TreeEditorTest|WarningsTest'`
+Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\Compose\TreeEditor" not found`.
+
+- [ ] **Step 3: Implementierung**
+
+`src/Integrations/Divi/Compose/TreeEditor.php`:
+```php
+ $input
+ * @return array{container: Container, parent: ?Block, index: int}|string Position or German error.
+ */
+ public function position(NodeIndex $index, array $input): array|string
+ {
+ $given = array_filter(
+ [
+ 'parent' => $input['parent'] ?? null,
+ 'before' => $input['before'] ?? null,
+ 'after' => $input['after'] ?? null,
+ ],
+ static fn (mixed $value): bool => is_string($value) && $value !== ''
+ );
+ if (count($given) > 1) {
+ return 'nur eines von parent, before oder after angeben';
+ }
+
+ $at = isset($input['index']) && is_int($input['index']) ? max(0, $input['index']) : PHP_INT_MAX;
+ if ($given === []) {
+ return ['container' => $index->root(), 'parent' => null, 'index' => $at];
+ }
+
+ $key = (string) array_key_first($given);
+ $node = $index->find((string) $given[$key]);
+ if ($node === null) {
+ return sprintf('Knoten "%s" gibt es nicht', (string) $given[$key]);
+ }
+
+ if ($key === 'parent') {
+ return ['container' => $node, 'parent' => $node, 'index' => $at];
+ }
+
+ $container = $index->parentOf($node);
+ if ($container === null) {
+ return sprintf('Knoten "%s" hat keinen Elternknoten', (string) $given[$key]);
+ }
+
+ $sibling = (int) $container->indexOf($node);
+
+ return [
+ 'container' => $container,
+ 'parent' => $container instanceof Block && $container->name() !== 'divi/placeholder' ? $container : null,
+ 'index' => $key === 'before' ? $sibling : $sibling + 1,
+ ];
+ }
+
+ /**
+ * @param array{container: Container, parent: ?Block, index: int} $position
+ * @param list $blocks
+ */
+ public function insert(array $position, array $blocks): ?string
+ {
+ foreach ($blocks as $block) {
+ $violation = $this->rules->violation($position['parent'], $block->name());
+ if ($violation !== null) {
+ return $violation;
+ }
+ }
+
+ foreach (array_values($blocks) as $offset => $block) {
+ $at = $position['index'] === PHP_INT_MAX ? PHP_INT_MAX : $position['index'] + $offset;
+ $position['container']->insertChild($at, $block);
+ }
+
+ return null;
+ }
+
+ /** @param array{container: Container, parent: ?Block, index: int} $position */
+ public function move(NodeIndex $index, Block $node, array $position): ?string
+ {
+ for ($ancestor = $position['parent']; $ancestor !== null; $ancestor = $this->parentBlock($index, $ancestor)) {
+ if ($ancestor === $node) {
+ return 'ein Knoten kann nicht in sich selbst verschoben werden';
+ }
+ }
+
+ $violation = $this->rules->violation($position['parent'], $node->name());
+ if ($violation !== null) {
+ return $violation;
+ }
+
+ $from = $index->parentOf($node);
+ if ($from === null) {
+ return 'der Knoten hat keinen Elternknoten';
+ }
+
+ $old = $from->removeChild($node);
+ $target = $position['index'];
+ if ($from === $position['container'] && $target !== PHP_INT_MAX && $old < $target) {
+ --$target;
+ }
+ $position['container']->insertChild($target, $node);
+
+ return null;
+ }
+
+ public function remove(NodeIndex $index, Block $node): void
+ {
+ $index->parentOf($node)?->removeChild($node);
+ }
+
+ /** Inserts a copy right after $node and returns it. */
+ public function duplicate(NodeIndex $index, Block $node): Block
+ {
+ $copy = $node->copy();
+ $container = $index->parentOf($node);
+ if ($container !== null) {
+ $container->insertChild((int) $container->indexOf($node) + 1, $copy);
+ }
+
+ return $copy;
+ }
+
+ private function parentBlock(NodeIndex $index, Block $block): ?Block
+ {
+ $parent = $index->parentOf($block);
+
+ return $parent instanceof Block && $parent->name() !== 'divi/placeholder' ? $parent : null;
+ }
+}
+```
+
+`src/Integrations/Divi/Compose/Warnings.php`:
+```php
+ 1, 'divi/blurb' => 4, 'divi/cta' => 2];
+
+ private const IMAGE_SOURCES = [
+ 'divi/image' => ['image.innerContent', 'src', 'alt'],
+ 'divi/blurb' => ['imageIcon.innerContent', 'src', 'alt'],
+ 'divi/slide' => ['image.innerContent', 'src', 'alt'],
+ ];
+
+ public function __construct(private DesignSystem $design)
+ {
+ }
+
+ /**
+ * @param list $touched Created or changed blocks (their descendants count too).
+ * @return list
+ */
+ public function collect(NodeIndex $index, array $touched): array
+ {
+ $blocks = [];
+ foreach ($touched as $block) {
+ foreach ($this->withDescendants($block) as $node) {
+ $blocks[spl_object_id($node)] = $node;
+ }
+ }
+
+ $warnings = [];
+ foreach ($blocks as $block) {
+ $id = $index->idOf($block) ?? '?';
+ array_push(
+ $warnings,
+ ...$this->code($block, $id),
+ ...$this->image($block, $id),
+ ...$this->empty($block, $id),
+ ...$this->fixedValues($block, $id)
+ );
+ }
+
+ return array_values(array_unique(array_merge($warnings, $this->headings($index, $blocks))));
+ }
+
+ /** @return list */
+ private function withDescendants(Block $block): array
+ {
+ $all = [$block];
+ if (! $block->isOpaque()) {
+ foreach ($block->children() as $child) {
+ array_push($all, ...$this->withDescendants($child));
+ }
+ }
+
+ return $all;
+ }
+
+ /** @return list */
+ private function code(Block $block, string $id): array
+ {
+ return in_array($block->name(), ['divi/code', 'divi/fullwidth-code'], true)
+ ? [sprintf('%s: Code-Modul verwendet – im Divi-Builder nicht visuell bearbeitbar; nur nutzen, wenn kein '
+ . 'natives Modul passt.', $id)]
+ : [];
+ }
+
+ /** @return list */
+ private function image(Block $block, string $id): array
+ {
+ $spec = self::IMAGE_SOURCES[$block->name()] ?? null;
+ if ($spec === null) {
+ return [];
+ }
+
+ $value = AttrMap::desktop($block->attrs(), $spec[0]);
+ $src = is_array($value) ? ($value[$spec[1]] ?? '') : '';
+ $alt = is_array($value) ? ($value[$spec[2]] ?? '') : '';
+
+ return is_string($src) && $src !== '' && (! is_string($alt) || trim($alt) === '')
+ ? [sprintf('%s: Bild ohne Alt-Text.', $id)]
+ : [];
+ }
+
+ /** @return list */
+ private function empty(Block $block, string $id): array
+ {
+ $name = $block->name();
+ if (in_array($name, ['divi/section', 'divi/row', 'divi/column', 'divi/group'], true)) {
+ return $block->children() === [] ? [sprintf('%s: leer (%s ohne Inhalt).', $id, $block->shortName())] : [];
+ }
+
+ $type = $block->shortName();
+ if (ShortformMap::childType($type) !== null && $block->children() === []) {
+ return [sprintf('%s: %s ohne Einträge.', $id, $type)];
+ }
+
+ $primary = ShortformMap::primary($type);
+ if ($primary === null || ShortformMap::childType($type) !== null) {
+ return [];
+ }
+
+ $values = ShortformMap::values($type, $block->attrs());
+ $value = $values[$primary] ?? '';
+
+ return NodeSummary::textOf($value) === '' ? [sprintf('%s: %s ist leer (%s fehlt).', $id, $type, $primary)] : [];
+ }
+
+ /** @return list */
+ private function fixedValues(Block $block, string $id): array
+ {
+ $warnings = [];
+ $fonts = array_filter($this->design->fonts());
+ foreach ($this->leaves($block->attrs()) as [$key, $value]) {
+ if (preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', trim($value)) === 1) {
+ $match = $this->similarColor($value);
+ if ($match !== null) {
+ $warnings[] = sprintf(
+ '%s: feste Farbe %s – globale Farbe "%s" (%s) passt.',
+ $id,
+ $value,
+ $match['label'],
+ DesignSystem::colorPlaceholder($match['id'], $match['label'])
+ );
+ }
+ } elseif ($key === 'family') {
+ $font = array_search(mb_strtolower(trim($value)), array_map('mb_strtolower', $fonts), true);
+ if ($font !== false) {
+ $warnings[] = sprintf('%s: feste Schrift %s – {{font:%s}} verwenden.', $id, $value, $font);
+ }
+ }
+ }
+
+ return $warnings;
+ }
+
+ /**
+ * Heading jumps (h2 → h4) in page order where the later heading belongs to a touched block.
+ *
+ * @param array $touched
+ * @return list