# 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', '